07: Testing, Deployment, DevOps: Best Practices You Know + Laravel Workflow

Chapter 07: Testing, Deployment, DevOps: Best Practices You Know + Laravel Workflow
Section titled “Chapter 07: Testing, Deployment, DevOps: Best Practices You Know + Laravel Workflow”Overview
Section titled “Overview”In Chapter 06, you mastered building REST APIs in Laravel—routes, controllers, resources, validation, authentication, and external integrations. You now understand how to create production-ready APIs. But building an API is only half the battle. Professional development requires testing your code, automating deployments, containerizing applications, and managing background jobs. This chapter is where your Python DevOps knowledge becomes your greatest asset.
If you’ve worked with pytest, GitHub Actions, Docker, Celery, or deployment platforms like Heroku or Railway, you already understand the fundamentals: write tests to catch bugs, automate CI/CD pipelines, containerize for consistency, queue jobs for async processing, and deploy to production. Laravel’s testing, deployment, and DevOps tooling follows the same principles with PHP syntax. The concepts are identical: pytest becomes PHPUnit, Celery becomes Laravel Queues, GitHub Actions workflows work the same way, and Docker is Docker. The only difference is syntax: @pytest.fixture becomes setUp(), and @celery.task becomes implements ShouldQueue.
This chapter is a comprehensive guide to professional development practices in Laravel. We’ll compare every major DevOps tool to Python equivalents, showing you Python code you know, then demonstrating the Laravel equivalent. You’ll master testing with PHPUnit (comparing to pytest), CI/CD workflows with GitHub Actions, Docker containerization, deployment platforms (Laravel Forge/Vapor vs Heroku/Railway), and background jobs with Laravel Queues (comparing to Celery). By the end, you’ll see that Laravel’s DevOps tooling isn’t fundamentally different—it’s the same professional practices with PHP syntax and Laravel’s delightful developer experience.
Prerequisites
Section titled “Prerequisites”Before starting this chapter, you should have:
- Completion of Chapter 06 or equivalent understanding of Laravel APIs
- Laravel 11.x installed (or ability to follow along with code examples)
- Experience with pytest or unittest (Python testing)
- Familiarity with GitHub Actions or similar CI/CD platforms
- Basic understanding of Docker and containerization
- Experience with background job queues (Celery preferred for comparison)
- Knowledge of deployment platforms (Heroku, Railway, or similar)
- Estimated Time: ~135 minutes
Verify your setup:
# Check PHP version (should show PHP 8.4+)php --version
# Check Composer is installedcomposer --version
# If you have Laravel installed, verify it worksphp artisan --version
# Expected output: Laravel Framework 11.x.x (or similar)
# Check if PHPUnit is available (comes with Laravel)php artisan test --version
# Check Docker (optional, for Step 4)docker --versionWhat You’ll Build
Section titled “What You’ll Build”By the end of this chapter, you will have:
- Side-by-side comparison examples (pytest/Celery/GitHub Actions → PHPUnit/Laravel Queues/GitHub Actions) for testing, CI/CD, Docker, deployment, and queues
- Understanding of PHPUnit vs pytest, including test structure, assertions, fixtures, and mocking
- Knowledge of Laravel’s testing helpers (HTTP testing, database transactions, factories)
- Mastery of GitHub Actions workflows for Laravel (comparing to Python workflows)
- Ability to containerize Laravel applications with Docker (comparing to Python Dockerfiles)
- Understanding of Laravel Forge and Vapor deployment (comparing to Heroku/Railway)
- Working knowledge of Laravel Queues vs Celery, including job creation, queue drivers, and scheduling
- Confidence in professional development practices equivalent to your Python DevOps knowledge
Quick Start
Section titled “Quick Start”Want to see how pytest maps to PHPUnit right away? Here’s a side-by-side comparison:
pytest (Python):
import pytest
def test_user_creation(): user = User(name="John Doe", email="john@example.com") assert user.name == "John Doe" assert user.email == "john@example.com"
def test_user_email_validation(): with pytest.raises(ValueError): User(email="invalid-email")PHPUnit (Laravel):
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\TestCase;use App\Models\User;use InvalidArgumentException;
class UserTest extends TestCase{ public function test_user_creation(): void { $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com' ]);
$this->assertEquals('John Doe', $user->name); $this->assertEquals('john@example.com', $user->email); }
public function test_user_email_validation(): void { $this->expectException(InvalidArgumentException::class);
new User(['email' => 'invalid-email']); }}See the pattern? Same concepts—test methods, assertions, exceptions—just different syntax! This chapter will show you how every Python testing and DevOps tool translates to Laravel.
Objectives
Section titled “Objectives”- Map pytest test structure and assertions to PHPUnit, understanding Laravel’s testing conventions
- Understand Laravel’s testing helpers (HTTP testing, database transactions, factories) vs pytest fixtures and Django factories
- Master mocking and faking in Laravel (comparing to pytest mocks and Python’s unittest.mock)
- Create GitHub Actions workflows for Laravel (comparing to Python CI/CD pipelines)
- Containerize Laravel applications with Docker (comparing to Python Dockerfile patterns)
- Deploy Laravel applications using Forge and Vapor (comparing to Heroku/Railway deployment)
- Implement background jobs with Laravel Queues (comparing to Celery tasks and scheduling)
- Recognize that professional development practices are universal—only syntax differs between Python and PHP
Step 1: Testing Fundamentals (~25 min)
Section titled “Step 1: Testing Fundamentals (~25 min)”Understand PHPUnit vs pytest, comparing test structure, assertions, fixtures, and Laravel’s HTTP testing capabilities.
Actions
Section titled “Actions”- pytest Test Example (Python):
The complete pytest example is available in pytest-test-example.py:
import pytestfrom datetime import datetime
class User: def __init__(self, name, email): self.name = name self.email = email self.created_at = datetime.now()
def test_user_creation(): user = User("John Doe", "john@example.com") assert user.name == "John Doe" assert user.email == "john@example.com" assert user.created_at is not None
def test_user_email_validation(): with pytest.raises(ValueError, match="Invalid email"): if "@" not in "invalid-email": raise ValueError("Invalid email")
def test_user_list(): users = [ User("John", "john@example.com"), User("Jane", "jane@example.com") ] assert len(users) == 2 assert users[0].name == "John"- PHPUnit Unit Test (Laravel):
The complete PHPUnit example is available in phpunit-test-example.php:
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\TestCase;use App\Models\User;use InvalidArgumentException;
class UserTest extends TestCase{ public function test_user_creation(): void { $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com' ]);
$this->assertEquals('John Doe', $user->name); $this->assertEquals('john@example.com', $user->email); $this->assertNotNull($user->created_at); }
public function test_user_email_validation(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid email');
if (!str_contains('invalid-email', '@')) { throw new InvalidArgumentException('Invalid email'); } }
public function test_user_list(): void { $users = [ new User(['name' => 'John', 'email' => 'john@example.com']), new User(['name' => 'Jane', 'email' => 'jane@example.com']) ];
$this->assertCount(2, $users); $this->assertEquals('John', $users[0]->name); }}- Laravel Feature Test (HTTP Testing) (PHP/Laravel):
The complete Laravel feature test example is available in laravel-feature-test.php:
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;
class UserApiTest extends TestCase{ use RefreshDatabase;
public function test_can_list_users(): void { User::factory()->count(3)->create();
$response = $this->getJson('/api/users');
$response->assertStatus(200) ->assertJsonStructure([ 'data' => [ '*' => ['id', 'name', 'email'] ] ]) ->assertJsonCount(3, 'data'); }
public function test_can_create_user(): void { $userData = [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123' ];
$response = $this->postJson('/api/users', $userData);
$response->assertStatus(201) ->assertJson([ 'data' => [ 'name' => 'John Doe', 'email' => 'john@example.com' ] ]);
$this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); }
public function test_can_update_user(): void { $user = User::factory()->create();
$response = $this->putJson("/api/users/{$user->id}", [ 'name' => 'Updated Name' ]);
$response->assertStatus(200);
$this->assertDatabaseHas('users', [ 'id' => $user->id, 'name' => 'Updated Name' ]); }
public function test_can_delete_user(): void { $user = User::factory()->create();
$response = $this->deleteJson("/api/users/{$user->id}");
$response->assertStatus(204);
$this->assertDatabaseMissing('users', [ 'id' => $user->id ]); }}Expected Result
Section titled “Expected Result”You can see the patterns are similar:
- pytest:
def test_name()→ PHPUnit:public function test_name(): void - pytest:
assert value == expected→ PHPUnit:$this->assertEquals($expected, $value) - pytest:
with pytest.raises()→ PHPUnit:$this->expectException() - pytest: Manual HTTP testing → Laravel: Built-in HTTP testing with
getJson(),postJson(), etc.
Why It Works
Section titled “Why It Works”Both testing frameworks follow similar principles:
- pytest: Uses Python’s assert statement with introspection for detailed failure messages
- PHPUnit: Uses assertion methods (
assertEquals,assertTrue, etc.) for explicit comparisons - Laravel Feature Tests: Extend PHPUnit with HTTP testing helpers (
getJson,postJson) and database assertions (assertDatabaseHas)
Laravel’s RefreshDatabase trait automatically migrates and rolls back your database for each test, ensuring test isolation. The getJson() and postJson() methods automatically set JSON headers and parse JSON responses, making API testing straightforward.
::: tip Running Tests
Run tests with php artisan test (Laravel’s wrapper) or vendor/bin/phpunit. Laravel’s test command provides better output and automatically loads environment variables from .env.testing.
:::
Comparison Table
Section titled “Comparison Table”| Feature | pytest | PHPUnit | Laravel Feature Tests |
|---|---|---|---|
| Test Method | def test_name() | public function test_name(): void | Same as PHPUnit |
| Assertion | assert value == expected | $this->assertEquals($expected, $value) | Same as PHPUnit |
| Exception Test | with pytest.raises() | $this->expectException() | Same as PHPUnit |
| HTTP Testing | Manual with requests | Manual with Guzzle | $this->getJson(), $this->postJson() |
| Database | Manual setup/teardown | Manual or setUp() | RefreshDatabase trait |
| Fixtures | @pytest.fixture | setUp() method | setUp() or factories |
Troubleshooting
Section titled “Troubleshooting”- “Test not found” — Make sure test methods start with
test_or use@testannotation. PHPUnit discovers tests by naming convention. - “Database connection error” — Ensure
.env.testingexists with test database configuration. Laravel uses a separate database for testing. - “Class not found” — Run
composer dump-autoloadto regenerate autoloader. Laravel’s test classes must be inTests\namespace. - “HTTP test failing” — Make sure routes exist and middleware is configured correctly. Use
php artisan route:listto verify routes.
Laravel HTTP Testing Helpers
Section titled “Laravel HTTP Testing Helpers”Laravel provides many HTTP testing helpers:
// GET request$response = $this->get('/api/users');$response->assertStatus(200);
// POST request with JSON$response = $this->postJson('/api/users', ['name' => 'John']);$response->assertStatus(201);
// PUT request$response = $this->putJson('/api/users/1', ['name' => 'Updated']);$response->assertStatus(200);
// DELETE request$response = $this->deleteJson('/api/users/1');$response->assertStatus(204);
// Assert JSON structure$response->assertJsonStructure(['data' => ['id', 'name']]);
// Assert JSON content$response->assertJson(['name' => 'John']);
// Assert database state$this->assertDatabaseHas('users', ['email' => 'john@example.com']);$this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']);Compare to Flask/Django testing:
# Flaskdef test_get_users(client): response = client.get('/api/users') assert response.status_code == 200 assert len(response.json) == 3
# Django RESTdef test_get_users(api_client): response = api_client.get('/api/users/') assert response.status_code == 200 assert len(response.data) == 3Laravel’s HTTP testing helpers provide a fluent, expressive API similar to Flask’s test client but with more built-in assertions.
Step 2: Test Coverage & Mocking (~20 min)
Section titled “Step 2: Test Coverage & Mocking (~20 min)”Compare pytest fixtures and mocks to PHPUnit mocks and Laravel’s testing features, including factories and fakes.
Actions
Section titled “Actions”- pytest Fixtures and Mocks (Python):
The complete pytest fixtures and mocks example is available in pytest-fixtures-mocks.py:
import pytestfrom unittest.mock import Mock, patchfrom datetime import datetime
class UserService: def __init__(self, email_service): self.email_service = email_service
def create_user(self, name, email): # Simulate user creation user = {'id': 1, 'name': name, 'email': email} self.email_service.send_welcome_email(email) return user
@pytest.fixturedef email_service(): return Mock()
@pytest.fixturedef user_service(email_service): return UserService(email_service)
def test_create_user_sends_email(user_service, email_service): user = user_service.create_user("John", "john@example.com")
assert user['name'] == "John" email_service.send_welcome_email.assert_called_once_with("john@example.com")
@patch('requests.get')def test_external_api_call(mock_get): mock_get.return_value.json.return_value = {'status': 'ok'}
import requests response = requests.get('https://api.example.com/data')
assert response.json()['status'] == 'ok' mock_get.assert_called_once_with('https://api.example.com/data')- PHPUnit Mocks and Laravel Fakes (PHP/Laravel):
The complete PHPUnit mocks and fakes example is available in phpunit-mocks-fakes.php:
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\TestCase;use App\Services\UserService;use App\Services\EmailService;use Illuminate\Support\Facades\Mail;use Illuminate\Support\Facades\Http;use Mockery;
class UserServiceTest extends TestCase{ public function test_create_user_sends_email(): void { // Create mock $emailService = Mockery::mock(EmailService::class); $emailService->shouldReceive('sendWelcomeEmail') ->once() ->with('john@example.com');
// Inject mock $userService = new UserService($emailService);
// Test $user = $userService->createUser('John', 'john@example.com');
$this->assertEquals('John', $user->name); Mockery::close(); }
public function test_external_api_call_with_fake(): void { // Fake HTTP client Http::fake([ 'api.example.com/*' => Http::response(['status' => 'ok'], 200) ]);
$response = Http::get('https://api.example.com/data');
$this->assertEquals('ok', $response->json()['status']);
Http::assertSent(function ($request) { return $request->url() === 'https://api.example.com/data'; }); }
public function test_mail_fake(): void { Mail::fake();
// Code that sends email Mail::to('user@example.com')->send(new \App\Mail\WelcomeEmail());
Mail::assertSent(\App\Mail\WelcomeEmail::class, function ($mail) { return $mail->hasTo('user@example.com'); }); }}- Laravel Model Factories (PHP/Laravel):
The complete Laravel factories example is available in laravel-factories.php:
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;use Illuminate\Database\Eloquent\Factories\Factory;use Illuminate\Support\Facades\Hash;
class UserFactory extends Factory{ protected $model = User::class;
public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'password' => Hash::make('password'), 'email_verified_at' => now(), ]; }
public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); }
public function admin(): static { return $this->state(fn (array $attributes) => [ 'role' => 'admin', ]); }}
// Usage in testsnamespace Tests\Feature;
use Tests\TestCase;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;
class UserTest extends TestCase{ use RefreshDatabase;
public function test_can_create_user(): void { $user = User::factory()->create();
$this->assertDatabaseHas('users', [ 'id' => $user->id, 'email' => $user->email ]); }
public function test_can_create_multiple_users(): void { User::factory()->count(5)->create();
$this->assertDatabaseCount('users', 5); }
public function test_can_create_unverified_user(): void { $user = User::factory()->unverified()->create();
$this->assertNull($user->email_verified_at); }
public function test_can_create_admin_user(): void { $admin = User::factory()->admin()->create();
$this->assertEquals('admin', $admin->role); }}Expected Result
Section titled “Expected Result”You can see the patterns are similar:
- pytest:
@pytest.fixture→ PHPUnit:setUp()method or dependency injection - pytest:
Mock()from unittest.mock → PHPUnit:Mockery::mock()or Laravel fakes - pytest:
@patchdecorator → Laravel:Http::fake(),Mail::fake(), etc. - Django: Factory classes → Laravel: Model factories with states
Why It Works
Section titled “Why It Works”All testing frameworks provide ways to isolate dependencies:
- pytest Fixtures: Reusable setup code that runs before tests, similar to PHPUnit’s
setUp() - Python Mocks: Replace dependencies with controllable objects
- Laravel Fakes: Replace facades (Mail, Http, Queue) with test doubles that record interactions
- Laravel Factories: Generate test data with realistic defaults, similar to Django factories or Factory Boy
Laravel’s fakes are particularly powerful because they work with facades—you can fake Mail, Http, Queue, Storage, and more without dependency injection. Factories provide a fluent API for creating test data with relationships and states.
::: tip Laravel Fakes
Laravel provides fakes for Mail, Http, Queue, Storage, Notification, and more. Use Mail::fake() to prevent emails from being sent during tests, Http::fake() to mock external API calls, and Queue::fake() to test queued jobs synchronously.
:::
Comparison Table
Section titled “Comparison Table”| Feature | pytest | PHPUnit | Laravel |
|---|---|---|---|
| Fixtures | @pytest.fixture | setUp() method | setUp() or traits |
| Mocks | Mock() from unittest.mock | Mockery::mock() | Mockery or fakes |
| Patching | @patch decorator | Manual injection | Facade fakes |
| Test Data | Manual or factories | Manual or factories | Model factories |
| States | Factory traits | Manual | Factory states |
| Relationships | Factory subfactories | Manual | Factory relationships |
Troubleshooting
Section titled “Troubleshooting”- “Mock not working” — Make sure you’re using
Mockery::close()intearDown()or use$this->afterApplicationCreated()callback. Mockery needs cleanup. - “Fake not working” — Ensure you call
Mail::fake()orHttp::fake()before the code that uses them. Fakes must be set up before execution. - “Factory not found” — Run
php artisan make:factory ModelFactoryto create factories. Laravel auto-discovers factories inDatabase\Factoriesnamespace. - “State not applying” — Make sure state methods return
$this->state()with a closure. States are applied when creating models.
Laravel Factory Relationships
Section titled “Laravel Factory Relationships”Factories can create related models:
public function definition(): array{ return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'company_id' => Company::factory(), // Creates related company ];}
// Usage$user = User::factory() ->has(Post::factory()->count(3)) // Create 3 posts ->create();Compare to Django factories:
# Django factoryclass UserFactory(factory.django.DjangoModelFactory): class Meta: model = User
name = factory.Faker('name') company = factory.SubFactory(CompanyFactory)
# Usageuser = UserFactory.create()user_with_posts = UserFactory.create(posts=PostFactory.create_batch(3))Laravel factories provide a similar fluent API for creating test data with relationships, making it easy to set up complex test scenarios.
Step 3: CI/CD Workflows (~25 min)
Section titled “Step 3: CI/CD Workflows (~25 min)”Compare GitHub Actions workflows for Python vs Laravel, understanding how to automate testing, linting, and deployment.
Actions
Section titled “Actions”- Python GitHub Actions Workflow:
The complete Python CI workflow example is available in python-ci.yml:
name: Python CI
on: push: branches: [main, develop] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest
strategy: matrix: python-version: ["3.10", "3.11", "3.12"]
steps: - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }}
- name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-cov
- name: Run tests run: | pytest --cov=./ --cov-report=xml
- name: Upload coverage uses: codecov/codecov-action@v3 with: file: ./coverage.xml
- name: Lint with flake8 run: | pip install flake8 flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Format check with black run: | pip install black black --check .- Laravel GitHub Actions Workflow:
The complete Laravel CI workflow example is available in laravel-ci.yml:
name: Laravel CI
on: push: branches: [main, develop] pull_request: branches: [main]
jobs: tests: runs-on: ubuntu-latest
strategy: matrix: php-version: ["8.2", "8.3", "8.4"]
services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps: - uses: actions/checkout@v4
- name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} extensions: mbstring, xml, mysql, pdo_mysql coverage: xdebug
- name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Generate key run: php artisan key:generate
- name: Directory Permissions run: chmod -R 755 storage bootstrap/cache
- name: Create Database run: | mkdir -p database touch database/database.sqlite
- name: Execute tests (Unit and Feature) with PHPUnit env: DB_CONNECTION: sqlite DB_DATABASE: database/database.sqlite run: vendor/bin/phpunit
- name: Run Pint (Code Style) run: | composer require --dev laravel/pint ./vendor/bin/pint --test
- name: Run Psalm (Static Analysis) run: | composer require --dev vimeo/psalm ./vendor/bin/psalm --no-cacheExpected Result
Section titled “Expected Result”You can see the patterns are similar:
- Python:
setup-python@v5→ PHP:setup-php@v2 - Python:
pytest→ PHP:phpunit - Python:
flake8/black→ PHP:pint/psalm - Python:
pip install -r requirements.txt→ PHP:composer install - Both use matrix strategies for multiple versions
Why It Works
Section titled “Why It Works”Both workflows follow the same CI/CD principles:
- Checkout code: Get the latest code from the repository
- Setup environment: Install runtime (Python/PHP) and dependencies
- Run tests: Execute test suite with coverage
- Lint/Format: Check code style and quality
- Deploy (optional): Deploy to staging/production on success
Laravel’s workflow includes database setup (SQLite for testing) and key generation, which are Laravel-specific requirements. Both workflows use matrix strategies to test against multiple versions, ensuring compatibility.
::: tip GitHub Actions Secrets
Store sensitive values (API keys, database passwords) in GitHub Secrets. Access them in workflows with ${{ "{{" }} secrets.SECRET_NAME }}. Never commit secrets to your repository.
:::
Comparison Table
Section titled “Comparison Table”| Feature | Python CI | Laravel CI |
|---|---|---|
| Setup Action | setup-python@v5 | setup-php@v2 |
| Dependency Manager | pip install -r requirements.txt | composer install |
| Test Runner | pytest | phpunit or php artisan test |
| Linter | flake8 | pint (Laravel Pint) |
| Formatter | black | pint (also formats) |
| Static Analysis | mypy | psalm or phpstan |
| Database | Manual setup | SQLite or MySQL service |
| Matrix Strategy | Python versions | PHP versions |
Troubleshooting
Section titled “Troubleshooting”- “PHP version not found” — Use
shivammathur/setup-php@v2which supports many PHP versions. Check available versions in the action’s documentation. - “Database connection failed” — Ensure database service is healthy before running tests. Use health checks in service configuration.
- “Composer install failed” — Check
composer.jsonsyntax. Usecomposer validatelocally before pushing. - “Tests failing in CI but passing locally” — Ensure
.envis properly configured. Use.env.exampleas a template and set test-specific values.
Deployment Step (Optional)
Section titled “Deployment Step (Optional)”Add deployment to your workflow:
deploy: needs: tests runs-on: ubuntu-latest if: github.ref == 'refs/heads/main'
steps: - uses: actions/checkout@v4
- name: Deploy to production run: | # Your deployment commands echo "Deploying to production..."Compare to Python deployment:
# Python deployment (e.g., Heroku)deploy: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: akhileshns/heroku-deploy@v3.12.12 with: heroku_api_key: ${{secrets.HEROKU_API_KEY}} heroku_app_name: "your-app-name" heroku_email: "your-email@example.com"Both workflows can deploy to various platforms (Heroku, Railway, Laravel Forge, etc.) after successful tests.
Step 4: Docker & Containerization (~20 min)
Section titled “Step 4: Docker & Containerization (~20 min)”Compare Docker setups for Python apps vs Laravel apps, understanding containerization patterns and docker-compose configurations.
Actions
Section titled “Actions”- Python Dockerfile (Flask/Django):
The complete Python Dockerfile example is available in Dockerfile.python:
# filename: DockerfileFROM python:3.11-slim
WORKDIR /app
# Install system dependenciesRUN apt-get update && apt-get install -y \ gcc \ postgresql-client \ && rm -rf /var/lib/apt/lists/*
# Copy requirementsCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
# Copy application codeCOPY . .
# Expose portEXPOSE 8000
# Run applicationCMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]- Laravel Dockerfile:
The complete Laravel Dockerfile example is available in Dockerfile.laravel:
# filename: DockerfileFROM php:8.4-fpm
# Install system dependenciesRUN apt-get update && apt-get install -y \ git \ curl \ libpng-dev \ libonig-dev \ libxml2-dev \ zip \ unzip \ && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Install ComposerCOPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directoryWORKDIR /var/www
# Copy application filesCOPY . /var/www
# Install dependenciesRUN composer install --no-dev --optimize-autoloader
# Set permissionsRUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
# Expose portEXPOSE 8000
# Start PHP-FPMCMD php artisan serve --host=0.0.0.0 --port=8000- Python docker-compose:
The complete Python docker-compose example is available in docker-compose.python.yml:
version: "3.8"
services: web: build: . ports: - "8000:8000" environment: - DATABASE_URL=postgresql://user:password@db:5432/mydb depends_on: - db
db: image: postgres:15 environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=mydb volumes: - postgres_data:/var/lib/postgresql/data
volumes: postgres_data:- Laravel docker-compose:
The complete Laravel docker-compose example is available in docker-compose.laravel.yml:
version: "3.8"
services: app: build: context: . dockerfile: Dockerfile ports: - "8000:8000" environment: - APP_ENV=local - DB_CONNECTION=mysql - DB_HOST=db - DB_DATABASE=laravel - DB_USERNAME=root - DB_PASSWORD=password volumes: - ./:/var/www depends_on: - db
db: image: mysql:8.0 environment: - MYSQL_DATABASE=laravel - MYSQL_ROOT_PASSWORD=password volumes: - mysql_data:/var/lib/mysql
redis: image: redis:alpine ports: - "6379:6379"
volumes: mysql_data:Expected Result
Section titled “Expected Result”You can see the patterns are similar:
- Python:
FROM python:3.11-slim→ PHP:FROM php:8.4-fpm - Python:
pip install -r requirements.txt→ PHP:composer install - Python:
gunicorn→ PHP:php artisan serveor PHP-FPM - Both use docker-compose for multi-container setups
- Both include database services (PostgreSQL/MySQL)
Why It Works
Section titled “Why It Works”Both Dockerfiles follow similar patterns:
- Base image: Official runtime image (Python/PHP)
- System dependencies: Install required system packages
- Application dependencies: Install language-specific packages (pip/composer)
- Copy code: Add application files to container
- Expose port: Make application accessible
- Run command: Start the application server
Laravel’s Dockerfile includes PHP extensions (pdo_mysql, mbstring, etc.) that are commonly needed. Both can use multi-stage builds for optimization (not shown here but recommended for production).
::: tip Multi-Stage Builds Use multi-stage builds to reduce image size. Build dependencies in one stage, copy only necessary files to a smaller runtime image in the final stage. :::
Comparison Table
Section titled “Comparison Table”| Feature | Python Dockerfile | Laravel Dockerfile |
|---|---|---|
| Base Image | python:3.11-slim | php:8.4-fpm |
| Dependency Manager | pip | composer |
| Dependency File | requirements.txt | composer.json |
| Web Server | gunicorn / uvicorn | php artisan serve / PHP-FPM + Nginx |
| Extensions | Python packages | PHP extensions (pdo_mysql, etc.) |
| Database | PostgreSQL/MySQL | MySQL/PostgreSQL |
| Compose Services | web + db | app + db + redis (optional) |
Troubleshooting
Section titled “Troubleshooting”- “Composer not found” — Ensure Composer is installed in the Dockerfile. Use
COPY --from=composer:latestfor multi-stage builds. - “Permission denied” — Set proper permissions for storage directories:
chown -R www-data:www-data storage bootstrap/cache - “Database connection failed” — Ensure database service is running and environment variables match docker-compose configuration.
- “Port already in use” — Change port mapping in docker-compose:
"8001:8000"instead of"8000:8000"
Step 5: Deployment Platforms (~20 min)
Section titled “Step 5: Deployment Platforms (~20 min)”Compare deployment options (Heroku/Railway vs Laravel Forge/Vapor), understanding platform-specific configurations and workflows.
Actions
Section titled “Actions”- Python Deployment (Heroku/Railway):
The complete Python Procfile example is available in Procfile.python:
# filename: Procfileweb: gunicorn app:app --bind 0.0.0.0:$PORT
# Or for Django# web: gunicorn myproject.wsgi:application --bind 0.0.0.0:$PORT
# Or for Railway# CMD: gunicorn app:app --bind 0.0.0.0:$PORTHeroku/Railway Configuration:
# runtime.txt (Heroku)python-3.11.5
Flask==2.3.0gunicorn==21.2.0psycopg2-binary==2.9.9
# Environment variables (set in platform dashboard)DATABASE_URL=postgresql://...SECRET_KEY=your-secret-key- Laravel Forge Deployment:
Laravel Forge is a server management platform that automates Laravel deployments:
Forge Setup:
- Connect your Git repository (GitHub, GitLab, Bitbucket)
- Select server (DigitalOcean, AWS, Linode, etc.)
- Configure deployment script:
# Forge deployment script (auto-generated)cd /home/forge/defaultgit pull origin main$FORGE_COMPOSER install --no-interaction --prefer-dist --optimize-autoloader --no-devphp artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cachephp artisan queue:restartEnvironment Variables:
Set in Forge dashboard:
APP_ENV=productionAPP_DEBUG=falseDB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_DATABASE=forgeDB_USERNAME=forgeDB_PASSWORD=...
- Laravel Vapor (Serverless):
The complete Laravel Vapor config example is available in vapor.yml:
id: 12345name: my-laravel-app
environments: production: domain: myapp.com memory: 1024 cli-memory: 512 runtime: php-8.4 build: - "composer install --no-dev --optimize-autoloader" - "php artisan config:cache" - "php artisan route:cache" - "php artisan view:cache" deploy: - "php artisan migrate --force"Vapor Deployment:
# Install Vapor CLIcomposer require laravel/vapor-cli --global
# Loginvapor login
# Deployvapor deploy productionExpected Result
Section titled “Expected Result”You can see the deployment patterns:
- Heroku/Railway: Simple Procfile/CMD → Laravel Forge: Git-based deployment with scripts
- Heroku/Railway: Platform-managed → Laravel Forge: Server management platform
- Heroku/Railway: Traditional hosting → Laravel Vapor: Serverless (AWS Lambda)
Why It Works
Section titled “Why It Works”All platforms automate deployment:
- Heroku/Railway: Detect runtime, install dependencies, run Procfile command
- Laravel Forge: Git push triggers deployment script (composer install, migrations, cache)
- Laravel Vapor: Serverless deployment to AWS Lambda with automatic scaling
Laravel Forge provides a GUI for server management (SSL, queues, cron jobs) while Vapor offers serverless scaling. Both are Laravel-specific, while Heroku/Railway are language-agnostic.
::: tip Deployment Best Practices
- Always run migrations in deployment scripts
- Cache configuration, routes, and views for production
- Use environment variables for secrets
- Set up queue workers for background jobs
- Configure SSL certificates
- Set up monitoring and logging :::
Comparison Table
Section titled “Comparison Table”| Feature | Heroku/Railway | Laravel Forge | Laravel Vapor |
|---|---|---|---|
| Deployment Method | Git push | Git push | vapor deploy |
| Configuration | Procfile/CMD | Deployment script | vapor.yml |
| Scaling | Manual dynos | Manual servers | Automatic (serverless) |
| Database | Add-on | Self-managed | RDS/DynamoDB |
| Queue | Worker dynos | Queue workers | SQS + Lambda |
| Cost | Pay per dyno | Pay per server | Pay per request |
| Best For | Simple apps | Traditional hosting | High-scale APIs |
Troubleshooting
Section titled “Troubleshooting”- “Deployment failed” — Check deployment logs in platform dashboard. Common issues: missing dependencies, migration failures, permission errors.
- “Environment variables not set” — Ensure variables are set in platform dashboard and match your
.envfile. - “Database connection failed” — Verify database credentials and ensure database is accessible from application server.
- “Queue not processing” — Ensure queue workers are running. In Forge, configure queue workers in dashboard. In Vapor, queues use SQS automatically.
Step 6: Background Jobs & Queues (~25 min)
Section titled “Step 6: Background Jobs & Queues (~25 min)”Compare Celery vs Laravel Queues, understanding job creation, queue drivers, and scheduling.
Actions
Section titled “Actions”- Celery Task (Python):
The complete Celery task example is available in celery-task.py:
from celery import Celeryimport requests
app = Celery('tasks', broker='redis://localhost:6379/0')
@app.taskdef send_email(to, subject, body): """Send email asynchronously""" # Simulate email sending print(f"Sending email to {to}: {subject}") return f"Email sent to {to}"
@app.task(bind=True, max_retries=3)def process_payment(self, user_id, amount): """Process payment with retry logic""" try: # Simulate payment processing if amount < 0: raise ValueError("Invalid amount") print(f"Processing payment for user {user_id}: ${amount}") return f"Payment processed: ${amount}" except Exception as exc: # Retry on failure raise self.retry(exc=exc, countdown=60)
# Usagefrom tasks import send_email, process_payment
# Dispatch tasksend_email.delay("user@example.com", "Welcome", "Welcome to our app!")process_payment.delay(user_id=1, amount=100.00)- Celery Beat (Scheduling):
The complete Celery Beat example is available in celery-beat.py:
from celery import Celeryfrom celery.schedules import crontab
app = Celery('tasks', broker='redis://localhost:6379/0')
app.conf.beat_schedule = { 'send-daily-report': { 'task': 'tasks.send_daily_report', 'schedule': crontab(hour=9, minute=0), # 9 AM daily }, 'cleanup-old-data': { 'task': 'tasks.cleanup_old_data', 'schedule': crontab(hour=2, minute=0, day_of_week=1), # 2 AM Monday },}
@app.taskdef send_daily_report(): print("Sending daily report...")
@app.taskdef cleanup_old_data(): print("Cleaning up old data...")- Laravel Job:
The complete Laravel job example is available in laravel-job.php:
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;use Illuminate\Support\Facades\Mail;
class SendEmailJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct( public string $to, public string $subject, public string $body ) {}
public function handle(): void { // Send email Mail::to($this->to)->send(new \App\Mail\WelcomeMail($this->subject, $this->body)); }
public function failed(\Throwable $exception): void { // Handle job failure logger()->error('Email job failed', [ 'to' => $this->to, 'error' => $exception->getMessage() ]); }}
// Usageuse App\Jobs\SendEmailJob;
// Dispatch jobSendEmailJob::dispatch('user@example.com', 'Welcome', 'Welcome to our app!');
// Dispatch with delaySendEmailJob::dispatch('user@example.com', 'Welcome', 'Welcome!') ->delay(now()->addMinutes(5));
// Dispatch to specific queueSendEmailJob::dispatch('user@example.com', 'Welcome', 'Welcome!') ->onQueue('emails');- Laravel Job with Retry Logic:
The complete Laravel job with retry logic example is available in laravel-job-retry.php:
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;
class ProcessPaymentJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3; // Max retries public $backoff = [60, 120, 300]; // Delay between retries (seconds)
public function __construct( public int $userId, public float $amount ) {}
public function handle(): void { if ($this->amount < 0) { throw new \InvalidArgumentException('Invalid amount'); }
// Process payment logger()->info('Processing payment', [ 'user_id' => $this->userId, 'amount' => $this->amount ]); }}- Laravel Scheduled Tasks:
The complete Laravel scheduled task example is available in laravel-scheduled-task.php:
<?php
declare(strict_types=1);
// app/Console/Kernel.phpnamespace App\Console;
use Illuminate\Console\Scheduling\Schedule;use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel{ protected function schedule(Schedule $schedule): void { // Run daily at 9 AM $schedule->call(function () { // Send daily report logger()->info('Sending daily report...'); })->dailyAt('09:00');
// Run weekly on Monday at 2 AM $schedule->call(function () { // Cleanup old data logger()->info('Cleaning up old data...'); })->weeklyOn(1, '02:00'); // 1 = Monday
// Run job on schedule $schedule->job(new \App\Jobs\SendDailyReportJob) ->dailyAt('09:00'); }}
// Add to crontab (run once):// * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1Expected Result
Section titled “Expected Result”You can see the patterns are similar:
- Celery:
@app.taskdecorator → Laravel:implements ShouldQueue - Celery:
task.delay()→ Laravel:Job::dispatch() - Celery:
bind=Truefor retries → Laravel:$triesand$backoffproperties - Celery Beat:
beat_schedule→ Laravel:schedule()method in Kernel
Why It Works
Section titled “Why It Works”Both queue systems follow similar principles:
- Task/Job Definition: Define work to be done asynchronously
- Queue Driver: Store jobs in Redis, database, or message queue (SQS, RabbitMQ)
- Worker: Process jobs from queue
- Retry Logic: Automatically retry failed jobs
- Scheduling: Run tasks on a schedule (cron-like)
Laravel’s queue system is simpler to set up than Celery (no separate broker process needed for database driver) but Celery offers more advanced features (task routing, result backends, etc.). Laravel’s scheduler uses a single cron entry that runs schedule:run every minute, which is simpler than Celery Beat’s separate process.
::: tip Queue Drivers
Laravel supports multiple queue drivers: sync (for testing), database, redis, sqs, beanstalkd. Use database for simple setups, redis for better performance, and sqs for AWS serverless.
:::
Comparison Table
Section titled “Comparison Table”| Feature | Celery | Laravel Queues |
|---|---|---|
| Task Definition | @app.task decorator | implements ShouldQueue |
| Dispatch | task.delay() | Job::dispatch() |
| Queue Driver | Redis, RabbitMQ, SQS | Database, Redis, SQS, Beanstalkd |
| Retry Logic | bind=True, max_retries | $tries, $backoff properties |
| Scheduling | Celery Beat | Laravel Scheduler |
| Worker | celery worker | php artisan queue:work |
| Monitoring | Flower, Celery monitoring | Horizon (for Redis), Queue dashboard |
| Result Backend | Redis, database, RPC | Optional (not built-in) |
Troubleshooting
Section titled “Troubleshooting”- “Job not processing” — Ensure queue worker is running:
php artisan queue:work. Use supervisor or systemd to keep workers running. - “Job failing silently” — Check
failed_jobstable:php artisan queue:failed. Implementfailed()method in job to handle failures. - “Scheduled task not running” — Ensure cron is set up:
* * * * * cd /path && php artisan schedule:run. Checkphp artisan schedule:listto see scheduled tasks. - “Redis connection failed” — Verify Redis is running and
QUEUE_CONNECTION=redisin.env. Test connection:redis-cli ping.
Queue Configuration
Section titled “Queue Configuration”'connections' => [ 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, ],
'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, ],],Compare to Celery configuration:
app = Celery('tasks')app.conf.broker_url = 'redis://localhost:6379/0'app.conf.result_backend = 'redis://localhost:6379/0'app.conf.task_serializer = 'json'app.conf.accept_content = ['json']app.conf.task_default_queue = 'default'Both systems provide flexible configuration for different queue backends and serialization formats.
Exercises
Section titled “Exercises”Practice professional development practices with these exercises:
Exercise 1: Write Tests for API Endpoints
Section titled “Exercise 1: Write Tests for API Endpoints”Goal: Write feature tests for REST API endpoints with authentication
Create feature tests for the User API endpoints from Chapter 06:
Requirements:
- Test
GET /api/users— List all users (with authentication) - Test
POST /api/users— Create user (with validation) - Test
PUT /api/users/{id}— Update user (with authentication) - Test
DELETE /api/users/{id}— Delete user (with authentication) - Use
RefreshDatabasetrait for database isolation - Use factories to create test data
- Assert JSON structure and status codes
- Test authentication middleware (should return 401 for unauthenticated requests)
Validation: Run tests and verify coverage:
# Run testsphp artisan test
# Run with coverage (if configured)php artisan test --coverage
# Expected: All tests pass with green outputExpected output: All tests pass, showing proper authentication, validation, and CRUD operations.
Exercise 2: Set Up CI/CD Pipeline
Section titled “Exercise 2: Set Up CI/CD Pipeline”Goal: Create GitHub Actions workflow for Laravel app
Requirements:
- Create
.github/workflows/laravel-ci.yml - Test against PHP 8.2, 8.3, and 8.4 (matrix strategy)
- Set up MySQL service for testing
- Run PHPUnit tests
- Run Laravel Pint (code style check)
- Run Psalm or PHPStan (static analysis)
- Only run on push to
mainand pull requests - Add deployment step (optional, can be commented out)
Validation: Push to GitHub and verify workflow runs:
# Commit and pushgit add .github/workflows/laravel-ci.ymlgit commit -m "Add CI workflow"git push origin main
# Check GitHub Actions tab# Expected: Workflow runs successfully with green checkmarksExpected output: GitHub Actions workflow runs successfully, showing all jobs passing (tests, linting, static analysis).
Exercise 3: Create Background Job
Section titled “Exercise 3: Create Background Job”Goal: Create a Laravel job that processes data asynchronously
Requirements:
- Create a job
ProcessUserDataJobthat:- Accepts a user ID
- Fetches user data from database
- Processes the data (e.g., generates report, sends notification)
- Handles failures gracefully
- Implement retry logic (3 attempts with exponential backoff)
- Create a scheduled task that runs the job daily at 9 AM
- Test the job with
Queue::fake()in a test
Validation: Test your implementation:
// In tinker or testuse App\Jobs\ProcessUserDataJob;use Illuminate\Support\Facades\Queue;
// Test job dispatchQueue::fake();ProcessUserDataJob::dispatch(1);Queue::assertPushed(ProcessUserDataJob::class);
// Test scheduled taskphp artisan schedule:list// Should show your scheduled job
// Run queue worker (in separate terminal)php artisan queue:workExpected output: Job dispatches correctly, processes data, and scheduled task appears in schedule list.
Wrap-up
Section titled “Wrap-up”Congratulations! You’ve completed a comprehensive guide to professional development practices in Laravel. Let’s review what you’ve accomplished:
-
Testing Fundamentals: You understand PHPUnit vs pytest, including test structure, assertions, fixtures, and Laravel’s HTTP testing capabilities. You can write unit tests and feature tests with database isolation.
-
Test Coverage & Mocking: You’ve mastered mocking and faking in Laravel, comparing them to pytest fixtures and Python mocks. You can use Laravel factories to create test data with relationships and states.
-
CI/CD Workflows: You can create GitHub Actions workflows for Laravel, comparing them to Python CI/CD pipelines. You understand how to automate testing, linting, and deployment.
-
Docker & Containerization: You can containerize Laravel applications with Docker, comparing Dockerfile patterns to Python applications. You understand docker-compose configurations for multi-container setups.
-
Deployment Platforms: You understand Laravel Forge and Vapor deployment, comparing them to Heroku/Railway. You know when to use each platform and how to configure deployments.
-
Background Jobs & Queues: You can implement background jobs with Laravel Queues, comparing them to Celery tasks. You understand queue drivers, retry logic, and scheduling.
Key Takeaways
Section titled “Key Takeaways”-
Testing Patterns Are Universal: The concepts of unit tests, feature tests, mocks, and fixtures work the same way across pytest and PHPUnit. Only syntax differs.
-
Laravel’s Developer Experience: Laravel’s testing helpers (
getJson,postJson,RefreshDatabase), fakes (Mail::fake,Http::fake), and factories provide a clean, intuitive testing experience that feels familiar to Python developers. -
CI/CD Is Language-Agnostic: GitHub Actions workflows work the same way for Python and PHP. The principles of checkout, setup, test, lint, and deploy are universal.
-
Docker Patterns Are Similar: Both Python and PHP Dockerfiles follow similar patterns: base image, dependencies, copy code, expose port, run command. The main differences are runtime-specific (pip vs composer, gunicorn vs PHP-FPM).
-
Deployment Options: Laravel Forge provides server management (similar to Heroku but more control), while Vapor offers serverless scaling (similar to AWS Lambda). Choose based on your needs: simple apps (Forge), high-scale APIs (Vapor).
-
Queues Are Queues: Celery and Laravel Queues follow the same principles: define tasks/jobs, dispatch to queue, process with workers, retry on failure. Laravel’s queue system is simpler to set up, while Celery offers more advanced features.
-
Professional Practices: Testing, CI/CD, containerization, deployment, and queues are essential for professional development. Laravel provides excellent tooling for all of these, making it easy to adopt best practices.
What’s Next?
Section titled “What’s Next?”In Chapter 08, you’ll explore Laravel’s ecosystem, community, and packages. You’ll learn about Livewire, Inertia, Laravel Nova, and other tools that make Laravel development delightful. You’ll also see where Laravel excels compared to Python frameworks.
Further Reading
Section titled “Further Reading”- Laravel Testing Documentation — Complete guide to testing in Laravel
- PHPUnit Documentation — PHPUnit testing framework reference
- Laravel Pint Documentation — Laravel’s code style fixer
- Laravel Queues Documentation — Complete guide to queues and jobs
- Laravel Forge Documentation — Server management platform
- Laravel Vapor Documentation — Serverless deployment platform
- GitHub Actions Documentation — CI/CD workflows
- Docker Documentation — Containerization platform
- pytest Documentation — Reference for comparison
- Celery Documentation — Reference for comparison