1. PHPUnit Basics
Installation & Setup

PHPUnit is Laravel's default testing framework. It comes pre-configured with Laravel.

composer require --dev phpunit/phpunit
php artisan make:test UserTest --unit
php artisan make:test UserControllerTest
Test Structure
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function test_addition()
    {
        $result = 2 + 2;
        $this->assertEquals(4, $result);
    }
}
Essential Assertions

Assertions (утверждения) — методы класса PHPUnit\Framework\TestCase, доступные в любом тестовом классе Laravel (Laravel-ный Tests\TestCase наследует его). Каждый метод проверяет, соответствует ли фактическое состояние ожидаемому. Не совпало — тест валится с диагностическим сообщением.

Почему через $this->: потому что тестовый класс наследует TestCase, где эти методы объявлены. Без них тесты не имеют смысла — код прогонится, но никто не проверит результат.

Assertion Purpose Example
assertEquals($expected, $actual) Значения равны (loose ==) $this->assertEquals(5, 2+3);
assertSame($expected, $actual) Идентичны (strict ===, тип тоже) $this->assertSame(5, 2+3); — '5' не пройдёт
assertNotEquals() / assertNotSame() Отрицания $this->assertNotEquals(0, $count);
assertTrue($condition) Выражение истинно $this->assertTrue($user->isAdmin());
assertFalse($condition) Выражение ложно $this->assertFalse($user->isDeleted());
assertNull($value) Значение равно null $this->assertNull($result);
assertNotNull($value) Значение не null $this->assertNotNull($user);
assertInstanceOf($class, $object) Объект — экземпляр класса (или наследника) $this->assertInstanceOf(Order::class, $order);
assertCount($n, $collection) Массив/коллекция содержит N элементов $this->assertCount(3, $users);
assertEmpty() / assertNotEmpty() Пусто / не пусто $this->assertEmpty($errors);
assertContains($needle, $haystack) Значение есть в массиве/коллекции $this->assertContains('admin', $roles);
assertArrayHasKey($key, $array) Массив содержит ключ $this->assertArrayHasKey('email', $data);
assertGreaterThan() / assertLessThan() Больше / меньше $this->assertGreaterThan(0, $total);
assertMatchesRegularExpression($regex, $string) Строка матчит regex $this->assertMatchesRegularExpression('/^ORD-\d+$/', $order->number);
assertStringContainsString($needle, $haystack) Подстрока в строке $this->assertStringContainsString('error', $log);
assertDatabaseHas($table, $data) (Laravel) В таблице БД есть запись с этими полями $this->assertDatabaseHas('orders', ['user_id' => 1]);
assertDatabaseMissing($table, $data) (Laravel) В таблице нет записи с этими полями $this->assertDatabaseMissing('users', ['email' => 'x@y']);
assertJson($response) (Laravel) Ответ содержит JSON $response->assertJson(['status' => 'ok']);
assertJsonStructure($structure) (Laravel) JSON имеет ожидаемую структуру ключей $response->assertJsonStructure(['data' => ['id', 'total']]);
Разбор реального теста: test_order_can_be_created

Типичный feature-тест, показывающий как assertion-методы работают вместе:

public function test_order_can_be_created()
{
    // Arrange — готовим окружение
    $user = User::factory()->create();

    // Act — одно действие
    $response = $this->actingAs($user)->post('/orders', [
        'items' => [['sku' => 'ABC-1', 'qty' => 2]],
    ]);

    // Assert — проверяем результат по нескольким осям
    $response->assertStatus(201);                          // HTTP-статус

    $order = Order::latest()->first();

    $this->assertInstanceOf(Order::class, $order);        // тип объекта
    $this->assertEquals($user->id, $order->user_id);      // связка user → order
    $this->assertDatabaseHas('orders', [                  // запись в БД
        'user_id' => $user->id,
        'status'  => 'pending',
    ]);
}

Что делает каждая проверка:

  • assertStatus(201) — HTTP-ответ 201 Created (метод TestResponse, не PHPUnit).
  • assertInstanceOf(Order::class, $order) — $order — экземпляр Order (или наследника). Если latest()->first() вернул null — тест упадёт здесь с понятным сообщением, а не на следующей строке с непонятным «Trying to get property of non-object».
  • assertEquals($user->id, $order->user_id) — заказ действительно привязан к тому пользователю, который его создавал.
  • assertDatabaseHas(...) — Laravel-специфичная проверка: в таблице orders реально лежит строка с этими полями. Более надёжно, чем полагаться только на in-memory объект.
Где какие используются
  • Unit-тесты — отдельные классы и методы (сервисы, actions, модели). Обычно только PHPUnit-ассерты: assertEquals, assertInstanceOf, assertTrue, assertCount.
  • Feature-тесты — маршрут → контроллер → middleware → БД. PHPUnit-ассерты + Laravel: assertStatus, assertRedirect, assertJson, assertJsonStructure, assertDatabaseHas, assertSee.
Test Lifecycle
class UserTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        // Runs before EACH test
        $this->user = User::factory()->create();
    }

    protected function tearDown(): void
    {
        // Runs after EACH test
        parent::tearDown();
    }

    public function test_user_exists()
    {
        $this->assertNotNull($this->user);
    }
}
Data Providers
/**
 * @dataProvider emailProvider
 */
public function test_email_validation($email, $isValid)
{
    $result = filter_var($email, FILTER_VALIDATE_EMAIL);
    $this->assertEquals($isValid, $result !== false);
}

public static function emailProvider()
{
    return [
        'valid email' => ['user@example.com', true],
        'invalid email' => ['invalid@', false],
        'no domain' => ['user@', false],
    ];
}
Running Tests
# Run all tests
php artisan test

# Run specific test class
php artisan test tests/Unit/UserTest.php

# Run tests matching pattern
php artisan test --filter=email_validation

# Run tests in parallel
php artisan test --parallel

# Run with coverage
php artisan test --coverage

# Run specific test group
php artisan test --group=auth
Test Groups
#[Group('auth')]
public function test_user_login()
{
    // ...
}
Проверь себя: PHPUnit Basics
Q1: What is the difference between setUp() and the constructor?
A: setUp() is called before EACH test method, while constructor is called once. setUp() is the standard place to initialize test fixtures.
Q2: When would you use @dataProvider?
A: When testing the same function with multiple input/output combinations. It reduces code duplication and makes tests more readable.
Exercise: Create PHPUnit Test

Task: Create a test file for a simple Calculator class with methods add(), subtract(), multiply(), divide(). Write tests using data providers to test multiple scenarios for each method. Run with coverage.

Hint: Create the Calculator class first, then write feature tests using test() function.

2. Unit Tests
What to Test in Unit Tests

Unit tests focus on small, isolated pieces of code. Test business logic, calculations, and pure functions.

  • Pure functions: Functions that always return the same output for same input
  • Service methods: Business logic in service classes
  • Calculations: Complex formulas and computations
  • Validators: Custom validation rules
  • Model methods: Computed properties, relationships (mocked)
Isolation with Mocking

Unit tests must be isolated. Use Mockery to mock dependencies:

use Mockery;

class OrderServiceTest extends TestCase
{
    public function test_order_total_with_discount()
    {
        // Mock the discount service
        $discountService = Mockery::mock(DiscountService::class);
        $discountService
            ->shouldReceive('getDiscount')
            ->with(100)
            ->andReturn(10); // 10% discount

        $service = new OrderService($discountService);
        $total = $service->calculateTotal(100);

        $this->assertEquals(90, $total);
    }
}
Practical Example: Order Calculator Service
// Service class
class OrderCalculator
{
    public function calculateTotal(Order $order, TaxService $taxService): float
    {
        $subtotal = $order->items->sum('price');
        $tax = $taxService->calculate($subtotal);
        return $subtotal + $tax;
    }
}

// Test
class OrderCalculatorTest extends TestCase
{
    public function test_calculates_total_with_tax()
    {
        $taxService = Mockery::mock(TaxService::class);
        $taxService->shouldReceive('calculate')->with(100)->andReturn(10);

        $order = Mockery::mock(Order::class);
        $order->items = collect([
            ['price' => 50],
            ['price' => 50],
        ]);

        $calculator = new OrderCalculator();
        $total = $calculator->calculateTotal($order, $taxService);

        $this->assertEquals(110, $total);
    }
}
Testing Pure Functions
class StringHelper
{
    public static function slugify(string $text): string
    {
        return strtolower(
            preg_replace('/[^a-z0-9]+/', '-', $text)
        );
    }
}

// No mocking needed for pure functions!
class StringHelperTest extends TestCase
{
    public function test_slugify()
    {
        $this->assertEquals('hello-world', StringHelper::slugify('Hello World'));
        $this->assertEquals('test-123', StringHelper::slugify('Test 123!'));
    }
}
Key Principle: Unit tests should run in milliseconds and require no database or external services. If a test touches the database, it's an integration test, not a unit test.
Проверь себя: Unit Tests
Q1: Why mock dependencies in unit tests?
A: Mocking isolates the unit being tested from external dependencies, making tests fast, reliable, and focused on a single responsibility.
Q2: Should you mock methods in the class you're testing?
A: No. You test the real implementation of the class. Only mock its dependencies (external services, repositories, etc).
3. Feature Tests
HTTP Testing Basics

Feature tests test entire HTTP requests/responses. They exercise controllers, middleware, and routing.

use Tests\TestCase;

class UserControllerTest extends TestCase
{
    public function test_can_view_users()
    {
        $response = $this->get('/api/users');

        $response->assertStatus(200);
        $response->assertJson(['data' => []]);
    }

    public function test_can_create_user()
    {
        $response = $this->post('/api/users', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);

        $response->assertStatus(201);
        $response->assertJson([
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);
    }
}
HTTP Method Testing
Method Usage
$this->get() GET request
$this->post() POST request
$this->put() PUT request (full update)
$this->patch() PATCH request (partial update)
$this->delete() DELETE request
Response Assertions
$response = $this->get('/users/1');

$response->assertStatus(200);              // Check HTTP status
$response->assertOk();                     // Assert 200
$response->assertJson(['name' => 'John']); // Check JSON
$response->assertJsonPath('user.id', 1);   // Check nested path
$response->assertSee('John');              // Check visible text
$response->assertRedirect('/dashboard');   // Check redirect
$response->assertCookie('session_id');     // Check cookie
$response->assertHeader('Content-Type');   // Check header
$response->assertJsonCount(5, 'data');     // Check array length
Testing Authenticated Routes
class ProtectedRouteTest extends TestCase
{
    use RefreshDatabase;

    public function test_unauthorized_user_cannot_access()
    {
        $response = $this->get('/dashboard');
        $response->assertRedirect('/login');
    }

    public function test_authenticated_user_can_access()
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->get('/dashboard');

        $response->assertOk();
    }

    public function test_only_admin_can_delete()
    {
        $user = User::factory()->create(['role' => 'user']);
        $admin = User::factory()->create(['role' => 'admin']);

        $this->actingAs($user)->delete('/users/1')->assertForbidden();
        $this->actingAs($admin)->delete('/users/1')->assertOk();
    }
}
RefreshDatabase Trait

Use RefreshDatabase trait to reset database after each test:

class UserApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_create_user()
    {
        User::factory()->count(5)->create();

        $response = $this->post('/api/users', ['name' => 'Jane']);

        $this->assertDatabaseCount('users', 6);
        $this->assertDatabaseHas('users', ['name' => 'Jane']);
    }
}
Testing JSON APIs
class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_api_returns_paginated_products()
    {
        Product::factory()->count(15)->create();

        $response = $this->getJson('/api/products?page=1');

        $response->assertOk()
                 ->assertJsonStructure([
                     'data' => [
                         '*' => ['id', 'name', 'price']
                     ],
                     'meta' => ['total', 'per_page', 'current_page']
                 ])
                 ->assertJsonCount(10, 'data');
    }
}
RefreshDatabase vs DatabaseTransactions: RefreshDatabase runs migrations after each test (slower but cleaner). DatabaseTransactions rolls back within a transaction (faster). Use RefreshDatabase for most tests.
Exercise: Feature Test for API Endpoint

Task: Write a complete feature test for a Blog API with endpoints: GET /blogs (paginated), POST /blogs (create), GET /blogs/{id}, PUT /blogs/{id}, DELETE /blogs/{id}. Test authentication, validation errors, and successful operations.

4. Mocking & Fakes in Laravel
When to Mock vs Fake

Fakes: Built-in Laravel test doubles that simulate behavior (Event::fake, Queue::fake). Use these when available.

Mocks (Mockery): Manual mocks for custom dependencies. Use when fakes don't exist.

Event Fakes
use Illuminate\Support\Facades\Event;

class OrderTest extends TestCase
{
    public function test_dispatches_order_created_event()
    {
        Event::fake();

        Order::create(['total' => 100]);

        Event::assertDispatched(OrderCreated::class);
    }

    public function test_event_contains_correct_data()
    {
        Event::fake();

        $order = Order::create(['total' => 100]);

        Event::assertDispatched(OrderCreated::class, function ($event) use ($order) {
            return $event->order->id === $order->id;
        });
    }
}
Queue Fakes
use Illuminate\Support\Facades\Queue;

class EmailTest extends TestCase
{
    public function test_sends_welcome_email_via_queue()
    {
        Queue::fake();

        User::create(['email' => 'user@example.com']);

        Queue::assertPushed(SendWelcomeEmail::class);
    }

    public function test_job_pushed_to_correct_queue()
    {
        Queue::fake();

        User::create(['email' => 'user@example.com']);

        Queue::assertPushedOn('emails', SendWelcomeEmail::class);
    }
}
Notification Fakes
use Illuminate\Support\Facades\Notification;

class OrderNotificationTest extends TestCase
{
    public function test_sends_notification_on_order_shipped()
    {
        Notification::fake();

        $order = Order::factory()->create();
        $order->update(['status' => 'shipped']);

        Notification::assertSentTo($order->user, OrderShipped::class);
    }
}
Mail Fakes
use Illuminate\Support\Facades\Mail;

class MailTest extends TestCase
{
    public function test_welcome_mail_sent()
    {
        Mail::fake();

        Mail::to('user@example.com')->send(new WelcomeEmail('John'));

        Mail::assertSent(WelcomeEmail::class, function ($mail) {
            return $mail->hasTo('user@example.com');
        });
    }
}
HTTP Fakes (External API Calls)
use Illuminate\Support\Facades\Http;

class PaymentServiceTest extends TestCase
{
    public function test_payment_gateway_integration()
    {
        Http::fake([
            'api.payment.com/*' => Http::response(['status' => 'success'], 200),
        ]);

        $result = PaymentService::charge('4111111111111111', 100);

        $this->assertTrue($result);
        Http::assertSent(function ($request) {
            return $request->url() == 'api.payment.com/charge';
        });
    }
}
Storage Fakes
use Illuminate\Support\Facades\Storage;

class UploadTest extends TestCase
{
    public function test_file_upload()
    {
        Storage::fake('avatars');

        $response = $this->post('/upload', [
            'avatar' => UploadedFile::fake()->image('avatar.jpg'),
        ]);

        Storage::disk('avatars')->assertExists('avatar.jpg');
    }
}
Fake vs Mock: Fakes are test doubles that Laravel provides. Mockery is a more powerful library for creating custom mocks. Prefer Laravel fakes when available (Event::fake, Queue::fake) over Mockery.
Exercise: Complete Mocking Scenario

Task: Write a test for a UserService that: 1) Creates a user, 2) Sends welcome email via queue, 3) Dispatches UserCreated event. Test all three behaviors are triggered without actually sending emails or processing queues.

5. Test-Driven Development (TDD)
The Red → Green → Refactor Cycle

Red: Write a failing test that describes desired behavior.

Green: Write minimal code to make the test pass.

Refactor: Improve code quality while keeping tests passing.

Real Example: Building a Discount System TDD-Style
Step 1: RED - Write the failing test
class DiscountServiceTest extends TestCase
{
    public function test_first_time_customer_gets_10_percent_discount()
    {
        $service = new DiscountService();
        $customer = Customer::factory()->create(['orders_count' => 0]);

        $discount = $service->calculateDiscount($customer);

        $this->assertEquals(0.10, $discount);
    }
}
Step 2: GREEN - Minimal implementation
class DiscountService
{
    public function calculateDiscount(Customer $customer): float
    {
        return 0.10; // Hardcoded, but test passes!
    }
}
Step 3: RED - Add another test
public function test_returning_customer_with_3_orders_gets_15_percent()
{
    $service = new DiscountService();
    $customer = Customer::factory()->create(['orders_count' => 3]);

    $discount = $service->calculateDiscount($customer);

    $this->assertEquals(0.15, $discount);
}
Step 4: GREEN - Real implementation
public function calculateDiscount(Customer $customer): float
{
    if ($customer->orders_count === 0) {
        return 0.10;
    }
    if ($customer->orders_count >= 3) {
        return 0.15;
    }
    return 0.05;
}
Step 5: REFACTOR - Improve without changing behavior
public function calculateDiscount(Customer $customer): float
{
    return match (true) {
        $customer->orders_count === 0 => 0.10,
        $customer->orders_count >= 3 => 0.15,
        default => 0.05,
    };
}
When TDD is Most Valuable
  • Complex business logic: TDD helps design the API and edge cases
  • Refactoring: Tests ensure behavior doesn't change
  • Legacy code: Write tests before modifying untested code
When TDD Can Be Overkill
  • Simple CRUD operations with standard patterns
  • Throwaway proof-of-concept code
  • Heavy UI work with frequent visual changes
Reference: Kent Beck - "Test Driven Development: By Example" is the definitive guide on TDD principles and practices.
Exercise: TDD A Complete Feature

Task: Use TDD to build a Product Recommendation system. Start with: 1) Customers get recommendations based on purchase history, 2) Similar products are ranked by relevance, 3) Bought items are excluded. Write tests first, then implementation.

6. Pest PHP
What is Pest?

Pest is a modern PHP testing framework built on PHPUnit. It's cleaner, more expressive syntax with focus on readability.

Installation
composer require --dev pestphp/pest pestphp/pest-plugin-laravel
Syntax: test() vs it()

Pest uses simple test() and it() functions instead of class-based tests:

// Pest syntax
test('user can login', function () {
    $user = User::factory()->create(['password' => 'secret']);

    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'secret',
    ]);

    $response->assertRedirect('/dashboard');
});

// Alternative: More readable with it()
it('logs in with valid credentials', function () {
    $user = User::factory()->create(['password' => 'secret']);

    $this->post('/login', [
        'email' => $user->email,
        'password' => 'secret',
    ])->assertRedirect('/dashboard');
});
Pest Expectations API
// Old PHPUnit style
$this->assertEquals(5, $result);
$this->assertTrue($user->isAdmin());

// Pest expectations (more fluent)
expect($result)->toBe(5);
expect($user->isAdmin())->toBeTrue();
expect($users)->toHaveCount(3);
expect($email)->toMatch('/\S+@\S+\.\S+/');
Higher-Order Tests

Pest allows skipping tests with ->skip() or marking as ->todo():

it('validates email format', function () {
    expect('invalid@')->not()->toMatch('/\S+@\S+\.\S+/');
})->skip();

it('implements payment processing', function () {
    // Not implemented yet
})->todo();
Arch Testing (Structural Tests)

Pest can test code architecture and dependencies:

use Pest\Arch\Targets\Target;

// Ensure Services don't depend on Controllers
arch()
    ->set('Services', 'app/Services')
    ->set('Controllers', 'app/Http/Controllers')
    ->expect('Services')
    ->not()
    ->toUse('Controllers');

// Ensure proper domain structure
arch()
    ->set('Models', 'app/Models')
    ->set('Jobs', 'app/Jobs')
    ->expect('Jobs')
    ->toUse('Models');
Migration from PHPUnit

You can mix PHPUnit and Pest in the same project. Gradually migrate:

# Convert existing tests
composer require pestphp/pest-plugin-drift --dev
php artisan pest:drift
Pest Advantages: Cleaner syntax, expectations API is more readable, arch testing for structural validation, built-in higher-order testing.
Exercise: Rewrite in Pest

Task: Take your PHPUnit tests from previous exercises and rewrite using Pest syntax. Use expectations API instead of assertions. Add arch tests to ensure your services don't depend on controllers.

7. Testing Pyramid
The Pyramid Structure
┌────────────────┐
│ E2E Tests │ Few, slow (seconds)
│ (UI/Browser) │ Slow feedback
└────────────────┘

┌──────────────────┐
│ Integration │ Some, medium speed
│ (API, Database) │ 100s milliseconds
└──────────────────┘

┌────────────────────┐
│ Unit Tests │ Many, fast (ms)
│ (Business Logic) │ Instant feedback
└────────────────────┘
Level 1: Unit Tests (Base)
  • Goal: Test isolated business logic
  • Coverage: 70-80% of code should be here
  • Speed: Run in milliseconds
  • Examples: Service methods, calculations, validators
  • Isolation: Mock all dependencies
Level 2: Integration Tests (Middle)
  • Goal: Test multiple components working together
  • Coverage: 15-20% of code
  • Speed: 100s of milliseconds
  • Examples: Repository queries, API endpoints, database transactions
  • Scope: Use real database or in-memory SQLite
Level 3: E2E Tests (Top)
  • Goal: Test complete user workflows
  • Coverage: 5-10% of code (critical happy paths)
  • Speed: Seconds per test
  • Examples: Complete purchase flow, user registration → login
  • Tools: Laravel Dusk, Selenium, Playwright
Coverage Targets
# Generate coverage report
php artisan test --coverage

# Expected by level:
# - Critical business logic: 80-95% coverage
# - Controllers/actions: 60-80% coverage
# - Simple getters/setters: 20-40% coverage
# - Overall target: 60-75% for health
Coverage Configuration
// phpunit.xml
<coverage processUncoveredFiles="true">
    <include>
        <directory suffix=".php">app</directory>
    </include>
    <exclude>
        <directory>app/Console</directory>
        <directory>app/Exceptions</directory>
    </exclude>
</coverage>
Pyramid Rule: Many fast unit tests, fewer integration tests, very few slow E2E tests. This gives you fast feedback while ensuring critical paths work end-to-end.
Reference: Martin Fowler's "TestPyramid" essay is the foundational work on this testing strategy.
Exercise: Analyze Test Pyramid

Task: Take an existing Laravel project (or create a simple one with 3 features). Write tests for it following the pyramid: 1) 10+ unit tests, 2) 3-4 integration tests, 3) 1 E2E test. Generate coverage report and identify gaps.

8. Docker Fundamentals
Images vs Containers

Image: Blueprint/template with OS, dependencies, code (immutable).

Container: Running instance of an image (mutable runtime).

# Images are like classes, containers are instances
docker images                    # List images
docker run image-name            # Create and run container from image
docker ps                        # List running containers
docker ps -a                     # List all containers
Dockerfile Instructions
Instruction Purpose Example
FROM Base image FROM php:8.2-fpm
RUN Execute command RUN apt-get install -y curl
COPY Copy from host to image COPY . /app
WORKDIR Set working directory WORKDIR /app
EXPOSE Expose port (documentation) EXPOSE 9000
CMD Default command CMD ["php-fpm"]
ENTRYPOINT Force command ENTRYPOINT ["php"]
Basic Dockerfile for Laravel
FROM php:8.2-fpm

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    libpq-dev \
    libzip-dev \
    && docker-php-ext-install pdo_pgsql zip

# Copy application
COPY . .

# Install composer dependencies
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN composer install --no-dev --optimize-autoloader

EXPOSE 9000

CMD ["php-fpm"]
Layers and Caching

Each instruction creates a layer. Docker caches layers for faster builds:

# Bad: System packages + composer install in one layer
RUN apt-get install -y curl && composer install

# Good: Separate layers for better caching
RUN apt-get update && apt-get install -y curl
RUN composer install

# Better: Install composer deps with cached composer.lock
COPY composer.* ./
RUN composer install --no-dev
COPY . .
RUN npm run build  # Only rebuilds if source changes
Multi-Stage Builds

Use multiple stages to reduce final image size:

# Stage 1: Builder
FROM php:8.2 as builder

WORKDIR /build
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN npm install && npm run build

# Stage 2: Runtime (smaller)
FROM php:8.2-fpm

WORKDIR /app
COPY --from=builder /build /app

EXPOSE 9000
CMD ["php-fpm"]
Building and Running
# Build image
docker build -t myapp:1.0 .

# Build with build args
docker build --build-arg PHP_VERSION=8.1 -t myapp .

# Run container
docker run -p 9000:9000 myapp:1.0

# Run with volume (live code)
docker run -v /path/to/app:/app -p 9000:9000 myapp:1.0

# Run in background
docker run -d -p 9000:9000 myapp:1.0

# Execute command in running container
docker exec container-name php artisan migrate
Image Size Matters: Every layer adds to image size. Combine RUN commands when possible, use .dockerignore to exclude files, and clean package manager caches.
Exercise: Build Multi-Stage Docker Image

Task: Create a multi-stage Dockerfile for Laravel that: 1) Builds assets in first stage, 2) Installs composer dependencies, 3) Final stage only contains runtime files. Verify image size reduction.

9. Docker Compose for Laravel
Full Stack Setup: PHP + Nginx + MySQL + Redis
version: '3.8'

services:
  app:
    build: .
    container_name: laravel_app
    working_dir: /app
    volumes:
      - ./:/app
    ports:
      - "9000:9000"
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    depends_on:
      - mysql
      - redis
    networks:
      - laravel

  nginx:
    image: nginx:alpine
    container_name: laravel_nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./:/app
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
      - ./docker/certs:/etc/nginx/certs
    depends_on:
      - app
    networks:
      - laravel

  mysql:
    image: mysql:8.0
    container_name: laravel_mysql
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_USER: laravel
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql.cnf:/etc/mysql/conf.d/my.cnf
    ports:
      - "3306:3306"
    networks:
      - laravel

  redis:
    image: redis:7-alpine
    container_name: laravel_redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - laravel

volumes:
  mysql_data:
  redis_data:

networks:
  laravel:
    driver: bridge
Essential Commands
# Start all services
docker-compose up -d

# View logs
docker-compose logs app
docker-compose logs -f nginx  # Follow logs

# Run one-off command
docker-compose exec app php artisan migrate
docker-compose exec mysql mysql -u root -proot laravel

# Stop services
docker-compose down

# Remove volumes (deletes data)
docker-compose down -v

# Rebuild images
docker-compose build --no-cache
Nginx Configuration Example
server {
    listen 80;
    server_name localhost;

    root /app/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}
Laravel Sail (Docker Alternative)

Laravel Sail is a pre-built Docker setup for Laravel:

# Install Sail in new project
composer require laravel/sail --dev
php artisan sail:install

# Start services
./vendor/bin/sail up -d

# Run commands
./vendor/bin/sail artisan migrate
./vendor/bin/sail tinker
Volumes: Bind mounts (./:/app) share host files with container. Named volumes (mysql_data:/var/lib/mysql) persist data across container restarts.
Exercise: Docker Compose Stack

Task: Create a complete docker-compose.yml for a Laravel project with: 1) PHP-FPM container, 2) Nginx reverse proxy, 3) PostgreSQL database, 4) Redis cache. Get a fresh Laravel app running in containers with all services communicating.

10. Git Advanced
Branching Strategies
GitFlow
  • main: Production-ready code
  • develop: Integration branch
  • feature/name: New features from develop
  • hotfix/name: Critical fixes from main
  • release/version: Release preparation
# GitFlow workflow
git checkout -b feature/user-auth develop
# ... make changes ...
git commit -m "feat: add user authentication"
git push origin feature/user-auth

# Create pull request, code review, then merge to develop
git checkout develop && git pull
git merge --no-ff feature/user-auth
git push origin develop
Trunk-Based Development
  • Short-lived feature branches (1-2 days max)
  • Frequent merges to main
  • Feature flags for incomplete features
  • Requires strong CI/CD pipeline
# Trunk-based: Quick iterations
git checkout -b feature/payment-processing
# ... 2 hours of work ...
git commit -m "feat: stripe payment integration"
git push && create PR

# Merge and immediately deploy
git checkout main && git pull
git merge feature/payment-processing
git push  # CI/CD deploys automatically
Rebase vs Merge
Merge (Default)
# Creates merge commit
git checkout main
git merge feature/x

# History shows branching:
# * commit (merge commit)
# |\
# | * feature commit
# | * feature commit
# |/
# * main commit
Rebase (Linear History)
# Replay commits on top of main
git rebase main

# History is linear:
# * feature commit 2
# * feature commit 1
# * main commit
Squash Commits Before Merge
# Interactive rebase to squash
git rebase -i HEAD~3

# Mark commits:
# pick abc123 First commit
# squash def456 Second commit (combine with above)
# squash ghi789 Third commit (combine with above)

# Result: Single clean commit
Cherry-Pick Specific Commits
# Apply specific commit to current branch
git cherry-pick abc123

# Useful for backporting fixes to release branch
git checkout release/v2.0
git cherry-pick bugfix-commit-hash
Stash for Context Switching
# Save work without committing
git stash

# Switch to another branch, fix urgent issue
git checkout hotfix/critical-bug
# ... fix and commit ...

# Return to original work
git checkout feature/my-feature
git stash pop
Conventional Commits

Standard commit format for clarity:

feat: add user authentication with OAuth
fix: resolve race condition in cache layer
docs: update API documentation
style: format code with PHP-CS-Fixer
refactor: simplify payment service
test: add unit tests for OrderCalculator
chore: upgrade Laravel to 10.0

# Breaking changes
feat!: change API response structure
Pre-Commit Hooks
#!/bin/sh
# .git/hooks/pre-commit

# Run PHP linter
./vendor/bin/php-cs-fixer fix --dry-run
if [ $? -ne 0 ]; then
    echo "PHP-CS-Fixer failed. Run: ./vendor/bin/php-cs-fixer fix"
    exit 1
fi

# Run tests
php artisan test --stop-on-failure
if [ $? -ne 0 ]; then
    echo "Tests failed. Fix them before committing."
    exit 1
fi

exit 0
Exercise: Git Workflow Practice

Task: 1) Create feature branches following GitFlow, 2) Rebase to squash messy commits, 3) Use cherry-pick to backport fix to release branch, 4) Write conventional commit messages throughout.

11. CI/CD with GitHub Actions
Workflow File Structure

Create .github/workflows/ci.yml:

name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        php-version: ['8.1', '8.2', '8.3']

    steps:
      - uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          extensions: pdo, pdo_mysql, redis
          ini-values: post_max_size=256M

      - name: Cache Composer packages
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist

      - name: Lint PHP
        run: |
          ./vendor/bin/php-cs-fixer fix --dry-run
          ./vendor/bin/phpstan analyse

      - name: Run tests
        run: php artisan test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml
Running Tests in Parallel
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - run: composer install
      - run: php artisan test tests/Unit --parallel

  integration-tests:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: test
          MYSQL_ROOT_PASSWORD: root
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
        ports:
          - 3306:3306
    steps:
      - uses: actions/checkout@v3
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - run: composer install
      - run: php artisan test tests/Feature
Complete CI/CD Pipeline with Deployment
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - run: composer install
      - run: php artisan test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3
      - uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            cd /var/www/app
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan cache:clear
            php artisan config:cache
            php artisan route:cache
            sudo systemctl restart app-queue
Useful Actions
Action Purpose
actions/checkout Clone repository
shivammathur/setup-php Install PHP with extensions
actions/cache Cache dependencies
codecov/codecov-action Upload coverage reports
appleboy/ssh-action SSH into server for deployment
Secrets Management: Never hardcode credentials. Use GitHub Secrets for sensitive data (DB passwords, SSH keys, API tokens).
Exercise: Complete CI/CD Pipeline

Task: Create a GitHub Actions workflow that: 1) Runs tests on push, 2) Tests multiple PHP versions in parallel, 3) Lints and analyzes code, 4) Uploads coverage, 5) Deploys to staging on PR merge.

12. Linux Basics for Backend
Essential Commands
Command Purpose Example
ls List files/directories ls -la /var/www
cd Change directory cd /app && pwd
grep Search text patterns grep "error" app.log
find Find files find . -name "*.log" -type f
chmod Change file permissions chmod 755 script.sh
chown Change file owner chown www-data:www-data /app
ps List processes ps aux | grep php
kill Terminate process kill -9 1234
systemctl Manage services systemctl restart nginx
tail View end of file tail -f /var/log/app.log
File Permissions (rwx)
# Permission format: user | group | others
# Read (r) = 4, Write (w) = 2, Execute (x) = 1

chmod 755 script.sh  # User: rwx (7), Group: r-x (5), Others: r-x (5)
chmod 644 file.txt   # User: rw- (6), Group: r-- (4), Others: r-- (4)
chmod 700 private    # User: rwx (7), Group: --- (0), Others: --- (0)

# For directories:
chmod 755 /var/www   # rwx for user, rx for group/others (can enter)
chmod 700 ~/.ssh     # Only user can enter
SSH Key Management
# Generate SSH key pair
ssh-keygen -t ed25519 -C "deploy@server"
# Generates: ~/.ssh/id_ed25519 (private) and ~/.ssh/id_ed25519.pub (public)

# Add public key to server
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.com

# SSH login without password
ssh -i ~/.ssh/id_ed25519 user@server.com

# Secure SSH config (~/.ssh/config)
Host production
  HostName prod.example.com
  User deploy
  IdentityFile ~/.ssh/id_ed25519
Cron Jobs
# Edit crontab
crontab -e

# Cron syntax: minute hour day month dayofweek command
# Run Laravel scheduler every minute
* * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1

# Examples:
0 2 * * * /backup.sh          # Daily at 2 AM
*/5 * * * * php /app/check.php # Every 5 minutes
0 0 1 * * /monthly-task.sh     # First day of month
0 0 * * 1 /weekly-task.sh      # Every Monday

# View active crons
crontab -l
Systemd Services
# Create service file
sudo nano /etc/systemd/system/laravel-queue.service

[Unit]
Description=Laravel Queue Worker
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/app
ExecStart=/usr/bin/php artisan queue:work redis --sleep=3 --tries=3
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

# Enable and start
sudo systemctl enable laravel-queue
sudo systemctl start laravel-queue
sudo systemctl status laravel-queue
Log Files
# Common log locations
/var/log/syslog          # System logs
/var/log/auth.log        # Authentication logs
/var/log/nginx/access.log # Nginx access
/var/log/nginx/error.log  # Nginx errors
/var/log/php-fpm.log      # PHP-FPM logs

# Monitor logs in real-time
tail -f /var/log/nginx/error.log

# Search logs
grep "GET /api" /var/log/nginx/access.log
grep -i error /var/log/app.log | tail -20
Exercise: Linux Server Setup

Task: 1) SSH into a test server, 2) Create appropriate directories with proper permissions, 3) Set up SSH key authentication, 4) Create a cron job for Laravel scheduler, 5) Create a systemd service for queue worker.

13. Nginx Configuration
Server Block (Virtual Host)
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    root /var/www/app/public;
    index index.php index.html;

    # SSL certificates
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # ... rest of config
}
Location Directives and Routing
server {
    root /var/www/app/public;

    # Exact match (highest priority)
    location = /admin {
        return 403;
    }

    # Prefix match
    location /api {
        # API routes are proxied to PHP
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Regex match (case-sensitive)
    location ~ \.php$ {
        # PHP files are processed by FPM
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Regex match (case-insensitive)
    location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
        # Static files - enable caching
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Everything else
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
}
Reverse Proxy to PHP-FPM
upstream php_upstream {
    server 127.0.0.1:9000;
    server 127.0.0.1:9001;  # Load balance across FPM instances
    keepalive 32;
}

server {
    listen 80;
    server_name app.local;
    root /app/public;

    location ~ \.php$ {
        fastcgi_pass php_upstream;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        # Improvements
        fastcgi_buffering off;
        fastcgi_request_buffering off;
        fastcgi_keep_conn on;

        include fastcgi_params;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
}
Gzip Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
           application/json application/javascript application/xml+rss;
Security Headers
server {
    # Prevent MIME sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # Enable XSS protection
    add_header X-XSS-Protection "1; mode=block" always;

    # Clickjacking protection
    add_header X-Frame-Options "SAMEORIGIN" always;

    # HSTS (force HTTPS)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Content Security Policy
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
}
Testing Nginx Config
# Validate syntax
nginx -t

# Reload without dropping connections
nginx -s reload

# Graceful restart
systemctl reload nginx

# View active config
nginx -T | grep -A 20 "server {"
Exercise: Complete Nginx Setup

Task: Create nginx config for a Laravel app with: 1) HTTP → HTTPS redirect, 2) SSL certificates, 3) PHP-FPM reverse proxy, 4) Static file caching, 5) Gzip compression, 6) Security headers, 7) Load balancing across multiple FPM instances.

14. Laravel Deployment
Zero-Downtime Deployment

Deploying without interrupting current users:

# Traditional: Downtime during deployment
git pull
composer install
php artisan migrate
# Site is down during these operations

# Zero-downtime: Use symlinks
# Current structure:
# /var/www/
#   releases/
#     release-2024-01-15/
#     release-2024-01-14/
#   app -> releases/release-2024-01-14

# During deployment:
git pull origin main
composer install
php artisan migrate  # Migrations are backwards compatible
# Atomically switch symlink
ln -sfn releases/release-2024-01-15 app

# If issue, revert instantly:
ln -sfn releases/release-2024-01-14 app
Deployer (PHP Deployment Tool)
// deploy.php
use Deployer as dep;

dep\config()
    ->set('application', 'myapp')
    ->set('repository', 'git@github.com:user/myapp.git')
    ->set('shared_files', ['.env'])
    ->set('shared_dirs', ['storage', 'bootstrap/cache'])
    ->set('writable_dirs', ['storage']);

// Hosts
dep\host('production')
    ->set('hostname', 'prod.example.com')
    ->set('user', 'deploy')
    ->set('deploy_path', '/var/www/app');

// Tasks
dep\task('deploy', [
    'deploy:prepare',
    'deploy:lock',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:vendors',
    'deploy:run_migrations',
    'deploy:cache_config',
    'deploy:cache_routes',
    'deploy:symlink',
    'deploy:unlock',
])->desc('Deploy application');

dep\task('deploy:run_migrations', function () {
    dep\run('cd {{release_path}} && php artisan migrate --force');
});

// Deploy
// deployer deploy
Post-Deployment Commands

Run these commands after deploying new code:

# Migrations (most important)
php artisan migrate --force

# Clear old caches
php artisan cache:clear
php artisan view:clear

# Recache for performance
php artisan config:cache
php artisan route:cache
php artisan event:cache

# Restart background workers
sudo systemctl restart laravel-queue
sudo systemctl restart laravel-scheduler

# Verify deployment
curl https://example.com/api/health
Server Requirements
Component Requirement
PHP 8.1+ (8.2+ recommended)
Extensions pdo, mbstring, json, bcmath, tokenizer, xml, ctype, fileinfo, filter, hash
Web Server Nginx 1.20+ or Apache 2.4+
Database MySQL 8.0+ / PostgreSQL 11+ / SQLite 3.8+
Cache Redis 5+ or Memcached 1.4+
Storage SSD with 20GB+ for app
Managed Hosting Platforms
  • Laravel Forge: Server management, zero-downtime deploys, SSL, backups, monitoring
  • DigitalOcean App Platform: Fully managed, auto-scaling, integrated database
  • AWS (Elastic Beanstalk): Auto-scaling, load balancing, managed infrastructure
  • Heroku: Simplest, but more expensive (buildpack approach)
Backwards-Compatible Migrations: Write migrations that don't break running instances. Add columns before removing, create new columns before deleting old ones.
Exercise: Deploy with Zero Downtime

Task: Set up Deployer for a Laravel project with: 1) Symlink-based releases, 2) Shared files/dirs, 3) Automated migrations, 4) Config/route caching, 5) Queue restart. Deploy and verify zero downtime.

15. Monitoring & Debugging
Laravel Telescope

Powerful debugging tool for Laravel applications:

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

# Access at: http://localhost:8000/telescope

Monitor:

  • HTTP requests & responses
  • Database queries
  • Cache hits/misses
  • Jobs & events
  • Log entries
  • Exceptions
Debugbar
composer require barryvdh/laravel-debugbar --dev

# Access toolbar at bottom of page in dev

Shows:

  • Execution time
  • Memory usage
  • Database queries with execution time
  • View variables
  • Route information
Error Tracking (Sentry)
composer require sentry/sentry-laravel

# Generate config
php artisan vendor:publish --provider="Sentry\Laravel\ServiceProvider"

# Configure .env
SENTRY_DSN=https://your-key@sentry.io/project-id

Automatically tracks:

  • Exceptions and errors
  • Performance issues
  • User sessions
  • Breadcrumbs (event trail)
Detecting N+1 Queries
// Problem: N+1 query
$users = User::all();
foreach ($users as $user) {
    echo $user->profile->bio; // Executes query for each user!
}

// Solution: Eager load
$users = User::with('profile')->get();
foreach ($users as $user) {
    echo $user->profile->bio; // No extra queries
}

// Detect in Laravel Telescope: View queries tab to spot duplicate patterns
APM (Application Performance Monitoring)

Tools like Datadog or New Relic monitor:

  • Transaction performance
  • Database performance
  • CPU & memory usage
  • Error rates
  • Throughput and latency
# Example: Datadog PHP agent
wget https://github.com/DataDog/dd-trace-php/releases/download/0.89.0/dd-library-php-0.89.0.tar.gz
tar -xf dd-library-php-0.89.0.tar.gz
cp dd-library-php /opt/datadog/

# Configure in php.ini
extension=/opt/datadog/dd_trace.so
datadog.trace.sample_rate=1.0
Log Aggregation

Centralize logs from all servers:

# Using ELK Stack (Elasticsearch, Logstash, Kibana)
# Send Laravel logs to Logstash
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['single', 'logstash'],
    ],
    'logstash' => [
        'driver' => 'monolog',
        'handler' => \Monolog\Handler\SocketHandler::class,
        'handler_with' => [
            'host' => 'logstash.example.com:5000',
        ],
    ],
]
Monitoring Strategy: Combine real-time debugging tools (Telescope, Debugbar) for development with production monitoring (Sentry, Datadog) for catching issues in production.
Exercise: Complete Monitoring Setup

Task: Set up comprehensive monitoring: 1) Install Telescope and verify it captures requests/queries, 2) Detect and fix N+1 query, 3) Configure Sentry for error tracking, 4) Monitor a slow endpoint and optimize it, 5) Set up Debugbar to profile performance.