07: Testing, Deployment, DevOps: Best Practices

07: Testing, Deployment, DevOps Intermediate
Section titled “07: Testing, Deployment, DevOps Intermediate”Overview
Section titled “Overview”Rails developers know the importance of testing and smooth deployments. Laravel provides excellent testing tools and deployment options that will feel familiar while offering some unique advantages.
This chapter covers everything from unit tests to production deployments, helping you translate your Rails DevOps knowledge to Laravel. You’ll discover how Laravel’s built-in testing tools compare to RSpec, how deployment workflows differ, and how to leverage Laravel’s powerful DevOps ecosystem.
By the end of this chapter, you’ll be able to write comprehensive tests, set up CI/CD pipelines, and deploy Laravel applications with confidence.
Prerequisites
Section titled “Prerequisites”Before starting this chapter, you should have:
- Completion of Chapter 06: Building REST APIs or equivalent understanding
- A Laravel application set up and running locally
- Basic familiarity with testing concepts (unit tests, integration tests)
- Git and GitHub account for CI/CD setup
- Estimated Time: ~90-120 minutes
Verify your setup:
# Check Laravel installationphp artisan --version
# Verify PHPUnit is available./vendor/bin/phpunit --versionWhat You’ll Build
Section titled “What You’ll Build”By the end of this chapter, you will have:
- A comprehensive test suite using PHPUnit and Pest
- Feature tests for API endpoints and authentication
- Test factories for generating test data
- A GitHub Actions CI/CD pipeline
- A deployment script for zero-downtime deployments
- Understanding of Laravel’s testing and deployment ecosystem
📦 Code Samples
Section titled “📦 Code Samples”Complete testing examples and deployment patterns:
- TaskMaster Tests — 16 comprehensive test cases using PHPUnit and Pest with factories and seeders
- TaskMaster Migrations — Database migration examples
- TaskMaster Factories — Factory patterns for test data generation
Access code samples:
git clone https://github.com/dalehurley/codewithphp.gitcd codewithphp/code/rails-developers-love-laravel/chapter-10Objectives
Section titled “Objectives”- Understand the differences between PHPUnit and RSpec testing approaches
- Write unit and feature tests for Laravel applications
- Set up test factories and database testing strategies
- Configure CI/CD pipelines with GitHub Actions
- Deploy Laravel applications using multiple strategies
- Implement zero-downtime deployment workflows
- Monitor and troubleshoot production applications
Testing: PHPUnit vs RSpec
Section titled “Testing: PHPUnit vs RSpec”Rails Testing Setup
Section titled “Rails Testing Setup”# Gemfilegroup :development, :test do gem 'rspec-rails' gem 'factory_bot_rails' gem 'faker'end
# spec/rails_helper.rbRSpec.configure do |config| config.use_transactional_fixtures = true config.include FactoryBot::Syntax::MethodsendLaravel Testing Setup
Section titled “Laravel Testing Setup”<?php# filename: composer.json (excerpt)// Laravel comes with PHPUnit pre-configured{ "require-dev": { "phpunit/phpunit": "^11.0", "mockery/mockery": "^1.6" }}<?phpnamespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase{ use CreatesApplication;
protected function setUp(): void { parent::setUp(); // Setup code }}Laravel includes testing tools out of the box - no additional gems needed.
Unit Tests
Section titled “Unit Tests”Rails Unit Test (RSpec)
Section titled “Rails Unit Test (RSpec)”require 'rails_helper'
RSpec.describe User, type: :model do describe 'validations' do it { should validate_presence_of(:name) } it { should validate_presence_of(:email) } it { should validate_uniqueness_of(:email) } end
describe 'associations' do it { should have_many(:posts) } end
describe '#full_name' do it 'returns first and last name' do user = create(:user, first_name: 'John', last_name: 'Doe') expect(user.full_name).to eq('John Doe') end end
describe '#active?' do it 'returns true for active users' do user = create(:user, status: 'active') expect(user.active?).to be true end endendLaravel Unit Test (PHPUnit)
Section titled “Laravel Unit Test (PHPUnit)”<?phpnamespace Tests\Unit;
use App\Models\User;use Illuminate\Database\Eloquent\Collection;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class UserTest extends TestCase{ use RefreshDatabase;
public function test_user_has_full_name_attribute(): void { $user = User::factory()->create([ 'first_name' => 'John', 'last_name' => 'Doe', ]);
$this->assertEquals('John Doe', $user->full_name); }
public function test_user_is_active(): void { $user = User::factory()->create(['status' => 'active']);
$this->assertTrue($user->isActive()); }
public function test_user_has_many_posts(): void { $user = User::factory() ->hasPosts(3) ->create();
$this->assertCount(3, $user->posts); $this->assertInstanceOf(Collection::class, $user->posts); }}Key Differences:
- PHPUnit uses methods instead of blocks (no
describe,it) - Test method names should start with
test_or use@testannotation - Assertions are methods:
$this->assertEquals(),$this->assertTrue() - RefreshDatabase trait handles database cleanup
Pest: Modern PHP Testing
Section titled “Pest: Modern PHP Testing”Laravel also supports Pest - a modern testing framework with Ruby-like syntax:
<?phpuse App\Models\User;
it('has full name attribute', function () { $user = User::factory()->create([ 'first_name' => 'John', 'last_name' => 'Doe', ]);
expect($user->full_name)->toBe('John Doe');});
it('is active when status is active', function () { $user = User::factory()->create(['status' => 'active']);
expect($user->isActive())->toBeTrue();});
it('has many posts', function () { $user = User::factory() ->hasPosts(3) ->create();
expect($user->posts)->toHaveCount(3);});Pest feels like RSpec! It’s becoming very popular in the Laravel community.
Feature Tests (Integration Tests)
Section titled “Feature Tests (Integration Tests)”Rails Feature Test
Section titled “Rails Feature Test”require 'rails_helper'
RSpec.describe 'Post Management', type: :feature do let(:user) { create(:user) }
before { sign_in user }
describe 'creating a post' do it 'allows user to create a new post' do visit new_post_path
fill_in 'Title', with: 'My New Post' fill_in 'Body', with: 'This is the content' click_button 'Create Post'
expect(page).to have_content('Post was successfully created') expect(page).to have_content('My New Post') end end
describe 'editing a post' do let(:post) { create(:post, user: user) }
it 'allows user to edit their post' do visit edit_post_path(post)
fill_in 'Title', with: 'Updated Title' click_button 'Update Post'
expect(page).to have_content('Post was successfully updated') expect(page).to have_content('Updated Title') end endendLaravel Feature Test
Section titled “Laravel Feature Test”<?phpnamespace Tests\Feature;
use App\Models\User;use App\Models\Post;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class PostManagementTest extends TestCase{ use RefreshDatabase;
public function test_user_can_create_post(): void { $user = User::factory()->create();
$response = $this->actingAs($user) ->post('/posts', [ 'title' => 'My New Post', 'body' => 'This is the content', ]);
$response->assertRedirect(route('posts.show', Post::first()));
$this->assertDatabaseHas('posts', [ 'title' => 'My New Post', 'user_id' => $user->id, ]); }
public function test_user_can_edit_their_post(): void { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user) ->put(route('posts.update', $post), [ 'title' => 'Updated Title', 'body' => $post->body, ]);
$response->assertRedirect(route('posts.show', $post));
$this->assertDatabaseHas('posts', [ 'id' => $post->id, 'title' => 'Updated Title', ]); }
public function test_user_cannot_edit_others_posts(): void { $user = User::factory()->create(); $otherUser = User::factory()->create(); $post = Post::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user) ->put(route('posts.update', $post), [ 'title' => 'Hacked Title', ]);
$response->assertForbidden();
$this->assertDatabaseMissing('posts', [ 'title' => 'Hacked Title', ]); }}Key Differences:
- Laravel tests HTTP endpoints directly (no Capybara browser simulation)
actingAs()authenticates a user- Fluent assertions:
assertRedirect(),assertForbidden() - Database assertions:
assertDatabaseHas(),assertDatabaseMissing()
With Pest
Section titled “With Pest”<?phpuse App\Models\User;use App\Models\Post;
it('allows user to create post', function () { $user = User::factory()->create();
$this->actingAs($user) ->post('/posts', [ 'title' => 'My New Post', 'body' => 'Content', ]) ->assertRedirect();
expect(Post::where('title', 'My New Post'))->toExist();});
it('prevents editing other users posts', function () { $user = User::factory()->create(); $post = Post::factory()->create();
$this->actingAs($user) ->put(route('posts.update', $post), ['title' => 'Hacked']) ->assertForbidden();});Much cleaner!
HTTP Testing Assertions
Section titled “HTTP Testing Assertions”Laravel provides powerful HTTP testing assertions that make API testing straightforward. Here are the most commonly used assertions:
JSON Assertions
Section titled “JSON Assertions”<?phpnamespace Tests\Feature;
use App\Models\User;use App\Models\Post;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class ApiPostTest extends TestCase{ use RefreshDatabase;
public function test_api_returns_post_as_json(): void { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user) ->getJson("/api/posts/{$post->id}");
// Assert JSON structure $response->assertJson([ 'id' => $post->id, 'title' => $post->title, 'user_id' => $user->id, ]);
// Assert JSON fragment (partial match) $response->assertJsonFragment([ 'title' => $post->title, ]);
// Assert JSON structure (keys exist, values can vary) $response->assertJsonStructure([ 'id', 'title', 'body', 'user' => [ 'id', 'name', ], 'created_at', ]);
// Assert exact JSON match $response->assertExactJson([ 'id' => $post->id, 'title' => $post->title, 'body' => $post->body, ]); }
public function test_api_validates_required_fields(): void { $user = User::factory()->create();
$response = $this->actingAs($user) ->postJson('/api/posts', []);
// Assert validation errors $response->assertStatus(422) ->assertJsonValidationErrors(['title', 'body']); }}Status Code and Header Assertions
Section titled “Status Code and Header Assertions”<?phppublic function test_response_assertions(): void{ $response = $this->get('/posts');
// Status codes $response->assertStatus(200); $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertAccepted(); // 202 $response->assertNoContent(); // 204 $response->assertNotFound(); // 404 $response->assertForbidden(); // 403 $response->assertUnauthorized(); // 401 $response->assertUnprocessable(); // 422 $response->assertServerError(); // 500
// Headers $response->assertHeader('Content-Type', 'application/json'); $response->assertHeaderMissing('X-Custom-Header');
// Cookies $response->assertCookie('session_id'); $response->assertCookie('session_id', 'value'); $response->assertCookieMissing('deleted_cookie');
// Session $response->assertSessionHas('key', 'value'); $response->assertSessionHas('key'); $response->assertSessionHasAll(['key1' => 'value1', 'key2' => 'value2']); $response->assertSessionMissing('key'); $response->assertSessionHasErrors(['field']); $response->assertSessionHasNoErrors();}View and Redirect Assertions
Section titled “View and Redirect Assertions”<?phppublic function test_view_and_redirect_assertions(): void{ $response = $this->get('/posts/create');
// View assertions $response->assertViewIs('posts.create'); $response->assertViewHas('post'); $response->assertViewHas('post', function ($post) { return $post->title === 'Expected Title'; }); $response->assertViewHasAll(['post', 'categories']); $response->assertViewMissing('deleted_variable');
// Redirect assertions $response->assertRedirect('/posts'); $response->assertRedirect(route('posts.index')); $response->assertRedirectToRoute('posts.show', ['post' => 1]);}Key Differences from Rails:
- Laravel’s HTTP assertions are more fluent and chainable
- JSON testing is built-in (no need for
json_responsehelpers) - Session and cookie assertions are more comprehensive
Database Testing
Section titled “Database Testing”Rails Database Strategy
Section titled “Rails Database Strategy”RSpec.configure do |config| # Transactional fixtures - roll back after each test config.use_transactional_fixtures = true
# Database cleaner for JavaScript tests config.before(:suite) do DatabaseCleaner.strategy = :transaction DatabaseCleaner.clean_with(:truncation) endendLaravel Database Strategy
Section titled “Laravel Database Strategy”<?phpnamespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Foundation\Testing\DatabaseMigrations;use Illuminate\Foundation\Testing\DatabaseTransactions;use Tests\TestCase;
class PostTest extends TestCase{ // Option 1: Refresh entire database before each test use RefreshDatabase;
// Option 2: Run migrations before each test use DatabaseMigrations;
// Option 3: Transactions (rolls back after each test) use DatabaseTransactions;
public function test_example(): void { // Database is clean for each test }}RefreshDatabase (most common):
- Runs migrations once per test suite
- Wraps each test in transaction
- Fast and clean
DatabaseMigrations:
- Runs migrations before each test
- Slower but more thorough
DatabaseTransactions:
- Wraps test in transaction
- Rolls back at end
- Fastest option
Factories and Test Data
Section titled “Factories and Test Data”Rails Factories (FactoryBot)
Section titled “Rails Factories (FactoryBot)”FactoryBot.define do factory :user do name { Faker::Name.name } email { Faker::Internet.email } password { 'password123' }
trait :admin do role { 'admin' } end
factory :user_with_posts do transient do posts_count { 5 } end
after(:create) do |user, evaluator| create_list(:post, evaluator.posts_count, user: user) end end endend
# Usageuser = create(:user)admin = create(:user, :admin)user_with_posts = create(:user_with_posts, posts_count: 3)Laravel Factories
Section titled “Laravel Factories”<?phpnamespace Database\Factories;
use App\Models\Post;use Illuminate\Database\Eloquent\Factories\Factory;use Illuminate\Support\Facades\Hash;
class UserFactory extends Factory{ public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'password' => Hash::make('password123'), ]; }
// State modifier (like Rails traits) public function admin(): static { return $this->state(fn (array $attributes) => [ 'role' => 'admin', ]); }
// With relationships public function withPosts(int $count = 5): static { return $this->has(Post::factory()->count($count)); }}
// Usage$user = User::factory()->create();$admin = User::factory()->admin()->create();$userWithPosts = User::factory()->withPosts(3)->create();
// Or using hasPosts relationship$user = User::factory() ->hasPosts(3) ->create();Laravel Factory Advantages:
- Type-safe with modern PHP
- Relationship methods auto-generated
- Fluent chaining
- Better IDE support
Factory Relationships
Section titled “Factory Relationships”Rails:
# Create user with posts and commentsuser = create(:user) do |u| create_list(:post, 3, user: u) do |post| create_list(:comment, 2, post: post) endendLaravel:
<?php# filename: tests/Feature/UserFactoryTest.php (example)use App\Models\User;use App\Models\Post;use App\Models\Comment;
// Create user with posts and comments$user = User::factory() ->has( Post::factory() ->count(3) ->has(Comment::factory()->count(2)) ) ->create();
// Or more readable (if relationships are defined)$user = User::factory() ->hasPosts(3) ->create();Laravel’s factory API is incredibly intuitive!
Mocking and Stubbing
Section titled “Mocking and Stubbing”Rails Mocking (RSpec)
Section titled “Rails Mocking (RSpec)”require 'rails_helper'
RSpec.describe PaymentService do describe '#charge' do let(:user) { create(:user) } let(:stripe_client) { instance_double(Stripe::Charge) }
before do allow(Stripe::Charge).to receive(:create).and_return(stripe_client) allow(stripe_client).to receive(:id).and_return('ch_123') end
it 'charges the user' do service = PaymentService.new(user) result = service.charge(100)
expect(Stripe::Charge).to have_received(:create).with( amount: 100, currency: 'usd', customer: user.stripe_id ) expect(result.transaction_id).to eq('ch_123') end endendLaravel Mocking (Mockery)
Section titled “Laravel Mocking (Mockery)”<?phpnamespace Tests\Unit;
use App\Services\PaymentService;use App\Models\User;use Stripe\Charge;use Tests\TestCase;
class PaymentServiceTest extends TestCase{ public function test_charges_user(): void { $user = User::factory()->create(['stripe_id' => 'cus_123']);
// Mock Stripe Charge $chargeMock = \Mockery::mock('overload:' . Charge::class); $chargeMock->shouldReceive('create') ->once() ->with([ 'amount' => 100, 'currency' => 'usd', 'customer' => 'cus_123', ]) ->andReturn((object) ['id' => 'ch_123']);
$service = new PaymentService(); $result = $service->charge($user, 100);
$this->assertEquals('ch_123', $result->transaction_id); }}Laravel Facades (Easier Mocking)
Section titled “Laravel Facades (Easier Mocking)”<?phpnamespace Tests\Feature;
use App\Mail\WelcomeEmail;use App\Models\User;use Illuminate\Support\Facades\Mail;use Illuminate\Support\Facades\Queue;use Illuminate\Support\Facades\Storage;use Illuminate\Support\Facades\Event;use Illuminate\Support\Facades\Notification;use Tests\TestCase;
class UserRegistrationTest extends TestCase{ public function test_sends_welcome_email(): void { // Fake the Mail facade Mail::fake();
$user = User::factory()->create(); $user->sendWelcomeEmail();
// Assert email was sent Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) { return $mail->hasTo($user->email); }); }}
// Other facade fakes you can use:// Queue::fake();// Storage::fake();// Event::fake();// Notification::fake();Laravel’s facade faking is easier than Rails mocking!
Testing Middleware, Events, and Commands
Section titled “Testing Middleware, Events, and Commands”Testing Middleware
Section titled “Testing Middleware”<?phpnamespace Tests\Unit\Middleware;
use App\Http\Middleware\EnsureUserIsActive;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Http\Request;use Tests\TestCase;
class EnsureUserIsActiveTest extends TestCase{ use RefreshDatabase;
public function test_allows_active_users(): void { $user = User::factory()->create(['status' => 'active']); $middleware = new EnsureUserIsActive(); $request = Request::create('/dashboard', 'GET'); $request->setUserResolver(fn () => $user);
$response = $middleware->handle($request, function ($req) { return response('OK'); });
$this->assertEquals('OK', $response->getContent()); }
public function test_blocks_inactive_users(): void { $user = User::factory()->create(['status' => 'inactive']); $middleware = new EnsureUserIsActive(); $request = Request::create('/dashboard', 'GET'); $request->setUserResolver(fn () => $user);
$response = $middleware->handle($request, function ($req) { return response('OK'); });
$this->assertEquals(403, $response->getStatusCode()); }}Testing Events
Section titled “Testing Events”<?phpnamespace Tests\Feature;
use App\Events\UserRegistered;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Support\Facades\Event;use Tests\TestCase;
class UserRegistrationTest extends TestCase{ use RefreshDatabase;
public function test_user_registration_fires_event(): void { Event::fake();
$user = User::factory()->create();
event(new UserRegistered($user));
// Assert event was dispatched Event::assertDispatched(UserRegistered::class);
// Assert event was dispatched with specific data Event::assertDispatched(UserRegistered::class, function ($event) use ($user) { return $event->user->id === $user->id; });
// Assert event was dispatched multiple times Event::assertDispatched(UserRegistered::class, 2);
// Assert event was not dispatched Event::assertNotDispatched(AnotherEvent::class); }
public function test_listener_handles_event(): void { Event::fake([UserRegistered::class]);
$user = User::factory()->create(); event(new UserRegistered($user));
// Listener should have been called // (test the side effects of the listener) $this->assertDatabaseHas('activity_logs', [ 'user_id' => $user->id, 'action' => 'registered', ]); }}Testing Artisan Commands
Section titled “Testing Artisan Commands”<?phpnamespace Tests\Feature\Commands;
use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Support\Facades\Mail;use Tests\TestCase;
class SendDailyDigestTest extends TestCase{ use RefreshDatabase;
public function test_sends_digest_to_all_users(): void { Mail::fake();
User::factory()->count(5)->create();
$this->artisan('users:send-digest') ->expectsOutput('Sending daily digest to 5 users...') ->assertExitCode(0);
Mail::assertSent(DailyDigestMail::class, 5); }
public function test_command_with_arguments(): void { $this->artisan('user:create', [ 'name' => 'John Doe', 'email' => 'john@example.com', ]) ->expectsQuestion('Enter password:', 'secret123') ->expectsOutput('User created successfully!') ->assertExitCode(0);
$this->assertDatabaseHas('users', [ 'name' => 'John Doe', 'email' => 'john@example.com', ]); }
public function test_command_with_options(): void { $this->artisan('posts:cleanup', ['--days' => 30]) ->expectsConfirmation('Delete posts older than 30 days?', 'yes') ->assertExitCode(0); }}Testing File Uploads
Section titled “Testing File Uploads”<?phpnamespace Tests\Feature;
use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Http\UploadedFile;use Illuminate\Support\Facades\Storage;use Tests\TestCase;
class FileUploadTest extends TestCase{ use RefreshDatabase;
public function test_user_can_upload_avatar(): void { Storage::fake('avatars'); $user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 100, 100);
$response = $this->actingAs($user) ->post('/profile/avatar', [ 'avatar' => $file, ]);
$response->assertRedirect('/profile');
// Assert file was stored Storage::disk('avatars')->assertExists($file->hashName());
// Assert file was stored with specific name Storage::disk('avatars')->assertExists("avatars/{$user->id}/{$file->hashName()}"); }
public function test_validates_file_type(): void { Storage::fake('avatars'); $user = User::factory()->create();
$file = UploadedFile::fake()->create('document.pdf', 1000);
$response = $this->actingAs($user) ->post('/profile/avatar', [ 'avatar' => $file, ]);
$response->assertSessionHasErrors(['avatar']); }}Test Organization
Section titled “Test Organization”Rails Test Structure
Section titled “Rails Test Structure”spec/├── models/├── controllers/├── requests/├── features/├── support/└── factories/Laravel Test Structure
Section titled “Laravel Test Structure”tests/├── Unit/ # Unit tests├── Feature/ # Integration tests├── Browser/ # Dusk browser tests (optional)└── TestCase.php # Base test classSimpler structure, clear separation.
Laravel Dusk (Browser Testing)
Section titled “Laravel Dusk (Browser Testing)”Laravel Dusk is Laravel’s browser testing tool, similar to Capybara in Rails. It uses ChromeDriver and provides a clean API for browser automation.
Installation
Section titled “Installation”# Install Duskcomposer require --dev laravel/dusk
# Install Duskphp artisan dusk:installBasic Dusk Test
Section titled “Basic Dusk Test”<?phpnamespace Tests\Browser;
use App\Models\User;use App\Models\Post;use Illuminate\Foundation\Testing\DatabaseMigrations;use Laravel\Dusk\Browser;use Tests\DuskTestCase;
class PostManagementTest extends DuskTestCase{ use DatabaseMigrations;
public function test_user_can_create_post(): void { $user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user) ->visit('/posts/create') ->type('title', 'My New Post') ->type('body', 'This is the content') ->press('Create Post') ->assertPathIs('/posts') ->assertSee('My New Post') ->assertSee('Post was successfully created'); }); }
public function test_user_can_edit_post(): void { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]);
$this->browse(function (Browser $browser) use ($user, $post) { $browser->loginAs($user) ->visit("/posts/{$post->id}/edit") ->type('title', 'Updated Title') ->press('Update Post') ->assertPathIs("/posts/{$post->id}") ->assertSee('Updated Title'); }); }
public function test_user_can_delete_post(): void { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]);
$this->browse(function (Browser $browser) use ($user, $post) { $browser->loginAs($user) ->visit("/posts/{$post->id}") ->press('Delete') ->acceptDialog() // Accept JavaScript confirm dialog ->assertPathIs('/posts') ->assertDontSee($post->title); }); }}Dusk Page Objects (Like Capybara Page Objects)
Section titled “Dusk Page Objects (Like Capybara Page Objects)”<?phpnamespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class PostCreatePage extends Page{ public function url(): string { return '/posts/create'; }
public function elements(): array { return [ '@title' => 'input[name="title"]', '@body' => 'textarea[name="body"]', '@submit' => 'button[type="submit"]', ]; }
public function createPost(Browser $browser, string $title, string $body): void { $browser->type('@title', $title) ->type('@body', $body) ->press('@submit'); }}<?php# Usage in testpublic function test_using_page_object(): void{ $user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user) ->visit(new PostCreatePage) ->createPost('My Post', 'Content') ->assertPathIs('/posts'); });}Dusk Assertions
Section titled “Dusk Assertions”<?php$browser->assertTitle('Page Title') ->assertUrlIs('https://example.com/posts') ->assertPathIs('/posts') ->assertSee('Text on page') ->assertDontSee('Text not on page') ->assertSeeIn('@element', 'Text') ->assertVisible('@element') ->assertPresent('@element') ->assertMissing('@element') ->assertInputValue('@input', 'value') ->assertChecked('@checkbox') ->assertSelected('@select', 'option') ->assertHasClass('@element', 'class-name') ->assertAttribute('@element', 'data-id', '123');Key Differences from Capybara:
- Dusk uses ChromeDriver (headless Chrome)
- More fluent API with method chaining
- Built-in page objects support
- Better integration with Laravel’s authentication
Running Tests
Section titled “Running Tests”# All testsbundle exec rspec
# Specific filebundle exec rspec spec/models/user_spec.rb
# Specific testbundle exec rspec spec/models/user_spec.rb:12
# With coverageCOVERAGE=true bundle exec rspec
# Parallel testsbundle exec parallel_test spec/Laravel
Section titled “Laravel”# All testsphp artisan test
# Or directly with PHPUnit./vendor/bin/phpunit
# Specific filephp artisan test tests/Feature/PostTest.php
# Specific testphp artisan test --filter test_user_can_create_post
# With coveragephp artisan test --coverage
# Parallel testsphp artisan test --parallelLaravel’s php artisan test provides prettier output than PHPUnit!
Continuous Integration
Section titled “Continuous Integration”Rails CI (GitHub Actions)
Section titled “Rails CI (GitHub Actions)”name: Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps: - uses: actions/checkout@v3
- name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: 3.2 bundler-cache: true
- name: Setup Database env: RAILS_ENV: test run: | bin/rails db:create bin/rails db:schema:load
- name: Run tests run: bundle exec rspecLaravel CI (GitHub Actions)
Section titled “Laravel CI (GitHub Actions)”name: Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: laravel_test options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps: - uses: actions/checkout@v3
- name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.4 extensions: mbstring, pdo_mysql coverage: xdebug
- name: Install Dependencies run: composer install --no-interaction --prefer-dist
- name: Copy Environment File run: cp .env.example .env
- name: Generate Application Key run: php artisan key:generate
- name: Run Migrations env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_DATABASE: laravel_test DB_USERNAME: root DB_PASSWORD: password run: php artisan migrate
- name: Run Tests env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_DATABASE: laravel_test DB_USERNAME: root DB_PASSWORD: password run: php artisan test --coverage --min=80Nearly identical workflow structure!
Deployment: Rails vs Laravel
Section titled “Deployment: Rails vs Laravel”Rails Deployment (Capistrano)
Section titled “Rails Deployment (Capistrano)”# Capfilerequire 'capistrano/rails'require 'capistrano/bundler'require 'capistrano/rbenv'require 'capistrano/puma'
# config/deploy.rbset :application, 'my_app'set :repo_url, 'git@github.com:user/repo.git'set :deploy_to, '/var/www/my_app'set :branch, 'main'
namespace :deploy do desc 'Restart application' task :restart do on roles(:app) do execute :touch, release_path.join('tmp/restart.txt') end end
after :publishing, :restartend
# Deploycap production deployLaravel Deployment (Envoy)
Section titled “Laravel Deployment (Envoy)”@servers(['web' => 'user@server.com'])
@setup $repository = 'git@github.com:user/repo.git'; $releases_dir = '/var/www/releases'; $app_dir = '/var/www/app'; $release = date('YmdHis'); $new_release_dir = $releases_dir .'/'. $release;@endsetup
@story('deploy') clone_repository run_composer update_symlinks optimize migrate reload_php@endstory
@task('clone_repository') echo 'Cloning repository' [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }} git clone --depth 1 {{ $repository }} {{ $new_release_dir }} cd {{ $new_release_dir }} git reset --hard {{ $commit }}@endtask
@task('run_composer') echo "Installing Composer dependencies" cd {{ $new_release_dir }} composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader@endtask
@task('update_symlinks') echo "Updating symlinks" ln -nfs {{ $new_release_dir }} {{ $app_dir }} ln -nfs {{ $app_dir }}/storage/app/public {{ $app_dir }}/public/storage@endtask
@task('optimize') echo "Optimizing application" cd {{ $app_dir }} php artisan config:cache php artisan route:cache php artisan view:cache@endtask
@task('migrate') echo "Running migrations" cd {{ $app_dir }} php artisan migrate --force@endtask
@task('reload_php') echo "Reloading PHP-FPM" sudo systemctl reload php8.4-fpm@endtask# Deploy using Envoyphp vendor/bin/envoy run deployBoth use similar task-based deployment strategies.
Laravel Forge (Managed Hosting)
Section titled “Laravel Forge (Managed Hosting)”Laravel Forge is like Heroku for Laravel (but better):
Features
Section titled “Features”- ✅ One-click server provisioning (AWS, DigitalOcean, etc.)
- ✅ Automatic deployments from Git
- ✅ SSL certificates (Let’s Encrypt)
- ✅ Database backups
- ✅ Queue workers management
- ✅ Scheduled jobs (cron)
- ✅ Server monitoring
- ✅ Easy rollbacks
Forge Deployment
Section titled “Forge Deployment”# 1. Connect your repository in Forge UI# 2. Configure deployment script (Forge provides default)# 3. Enable quick deploy on push
# Default Forge deploy script:cd /home/forge/example.comgit pull origin maincomposer install --no-interaction --prefer-dist --optimize-autoloader
php artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cachephp artisan queue:restart
# 4. Push to trigger deploymentgit push origin mainForge is significantly easier than managing Rails servers!
Docker and Containerization
Section titled “Docker and Containerization”Docker provides a consistent environment across development, staging, and production, similar to how Rails developers use Docker.
Basic Docker Setup
Section titled “Basic Docker Setup”# 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
# Install PHP extensionsRUN 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 applicationCOPY . /var/www
# Install dependenciesRUN composer install --optimize-autoloader --no-dev
# Set permissionsRUN chown -R www-data:www-data /var/wwwRUN chmod -R 755 /var/www/storage
EXPOSE 9000CMD ["php-fpm"]Docker Compose for Development
Section titled “Docker Compose for Development”version: '3.8'
services: app: build: context: . dockerfile: Dockerfile container_name: laravel_app restart: unless-stopped working_dir: /var/www volumes: - ./:/var/www - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini networks: - laravel
nginx: image: nginx:alpine container_name: laravel_nginx restart: unless-stopped ports: - "8080:80" volumes: - ./:/var/www - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf networks: - laravel
db: image: mysql:8.0 container_name: laravel_db restart: unless-stopped environment: MYSQL_DATABASE: laravel MYSQL_ROOT_PASSWORD: root MYSQL_PASSWORD: secret MYSQL_USER: laravel volumes: - dbdata:/var/lib/mysql networks: - laravel
redis: image: redis:alpine container_name: laravel_redis restart: unless-stopped ports: - "6379:6379" networks: - laravel
volumes: dbdata: driver: local
networks: laravel: driver: bridgeRunning Laravel in Docker
Section titled “Running Laravel in Docker”# Build and start containersdocker-compose up -d
# Run migrationsdocker-compose exec app php artisan migrate
# Run testsdocker-compose exec app php artisan test
# View logsdocker-compose logs -f app
# Stop containersdocker-compose down
# Rebuild after changesdocker-compose up -d --buildProduction Docker Deployment
Section titled “Production Docker Deployment”# Build production imagedocker build -t myapp:latest .
# Run containerdocker run -d \ --name myapp \ -p 80:9000 \ -v $(pwd)/.env:/var/www/.env \ myapp:latest
# Or use docker-compose for productiondocker-compose -f docker-compose.prod.yml up -dDocker with Laravel Sail
Section titled “Docker with Laravel Sail”Laravel Sail is Laravel’s official Docker development environment:
# Install Sailcomposer require laravel/sail --dev
# Publish Sail configphp artisan sail:install
# Start Sail./vendor/bin/sail up -d
# Run commands./vendor/bin/sail artisan migrate./vendor/bin/sail npm install./vendor/bin/sail testKey Differences from Rails:
- Laravel Sail provides Docker setup out of the box
- Similar Docker patterns to Rails
- PHP-FPM instead of Puma/Unicorn
- Nginx instead of Passenger (typically)
Zero-Downtime Deployments
Section titled “Zero-Downtime Deployments”Rails (Puma with Phased Restart)
Section titled “Rails (Puma with Phased Restart)”workers ENV.fetch("WEB_CONCURRENCY") { 2 }preload_app!
on_worker_boot do ActiveRecord::Base.establish_connectionend
# Phased restartpumactl phased-restartLaravel (Octane or FPM Reload)
Section titled “Laravel (Octane or FPM Reload)”# Option 1: Laravel Octane (recommended)php artisan octane:reload
# Option 2: PHP-FPM reloadsudo systemctl reload php8.4-fpm
# Option 3: Swoole hot reloadphp artisan octane:reload --swooleLaravel Octane provides instant reloads without downtime.
Blue-Green Deployments
Section titled “Blue-Green Deployments”Blue-green deployment is a strategy that reduces downtime and risk by running two identical production environments. Only one environment is live at a time.
Concept
Section titled “Concept”- Blue Environment: Current production (live)
- Green Environment: New version (staging)
- Switch traffic from Blue to Green when ready
- Keep Blue as fallback
Implementation with Laravel
Section titled “Implementation with Laravel”# 1. Set up two identical servers/environments# Blue: production-blue.example.com# Green: production-green.example.com
# 2. Deploy to Green (non-live)cd /var/www/greengit pull origin maincomposer install --no-dev --optimize-autoloaderphp artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cache
# 3. Test Green environmentcurl https://production-green.example.com/health
# 4. Switch load balancer to Green# (Update DNS or load balancer config)
# 5. Monitor Greentail -f /var/www/green/storage/logs/laravel.log
# 6. If issues, switch back to Blue# If successful, Blue becomes new staging for next deploymentUsing Laravel Forge
Section titled “Using Laravel Forge”Forge supports blue-green deployments:
# In Forge deployment scriptcd /home/forge/green.example.comgit pull origin maincomposer install --no-dev --optimize-autoloaderphp artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cache
# Switch DNS/load balancer to green# Monitor and verify# Then update blue for next deploymentDatabase Considerations
Section titled “Database Considerations”# Run migrations on Green BEFORE switching# Both environments can share same database# OR use database replication
# Option 1: Shared database (simpler)# Both Blue and Green connect to same database# Migrations run on Green before switch
# Option 2: Database replication (safer)# Green has its own database# Replicate data before switch# More complex but allows rollbackAutomated Blue-Green Script
Section titled “Automated Blue-Green Script”#!/bin/bashset -e
GREEN_DIR="/var/www/green"BLUE_DIR="/var/www/blue"CURRENT_ENV=$(cat /var/www/current-env.txt)
if [ "$CURRENT_ENV" = "blue" ]; then DEPLOY_TO="green" DEPLOY_DIR=$GREEN_DIRelse DEPLOY_TO="blue" DEPLOY_DIR=$BLUE_DIRfi
echo "🚀 Deploying to $DEPLOY_TO environment..."
cd $DEPLOY_DIRgit pull origin maincomposer install --no-dev --optimize-autoloaderphp artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cache
# Health checkif curl -f http://$DEPLOY_TO.example.com/health; then echo "✅ Health check passed"
# Switch traffic (update load balancer/DNS) echo "$DEPLOY_TO" > /var/www/current-env.txt
echo "✅ Switched to $DEPLOY_TO environment"else echo "❌ Health check failed - keeping current environment" exit 1fiBenefits:
- Zero downtime deployments
- Easy rollback (switch back to previous environment)
- Test new version before going live
- Reduced risk
Database Migrations in Production
Section titled “Database Migrations in Production”# Run migrationsRAILS_ENV=production bundle exec rails db:migrate
# Rollback if neededRAILS_ENV=production bundle exec rails db:rollback
# Check migration statusRAILS_ENV=production bundle exec rails db:migrate:statusLaravel
Section titled “Laravel”# Run migrationsphp artisan migrate --force
# Rollback last batchphp artisan migrate:rollback
# Rollback specific stepsphp artisan migrate:rollback --step=3
# Check migration statusphp artisan migrate:status
# Refresh (drop all + migrate - DANGEROUS!)php artisan migrate:fresh --forceBest Practice (Both Frameworks):
- Test migrations locally first
- Backup database before migration
- Make migrations reversible
- Deploy migrations separately from code
- Monitor application during migration
Environment Configuration
Section titled “Environment Configuration”Rails (.env)
Section titled “Rails (.env)”DATABASE_URL=postgresql://user:pass@host/dbREDIS_URL=redis://localhost:6379/0SECRET_KEY_BASE=long_secret_keyRAILS_ENV=productionRACK_ENV=productionLaravel (.env)
Section titled “Laravel (.env)”APP_ENV=productionAPP_DEBUG=falseAPP_KEY=base64:generated_key
DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=laravelDB_USERNAME=rootDB_PASSWORD=secret
REDIS_HOST=127.0.0.1REDIS_PASSWORD=nullREDIS_PORT=6379
CACHE_DRIVER=redisQUEUE_CONNECTION=redisSESSION_DRIVER=redisLaravel’s .env is more verbose but clearer.
Asset Compilation and Bundling
Section titled “Asset Compilation and Bundling”Laravel uses Vite (or Laravel Mix) for asset compilation, similar to Webpacker in Rails.
Laravel Vite (Recommended)
Section titled “Laravel Vite (Recommended)”# Install Vite (included in Laravel 9+)npm install
# Developmentnpm run dev
# Production buildnpm run buildimport { defineConfig } from 'vite';import laravel from 'laravel-vite-plugin';
export default defineConfig({ plugins: [ laravel({ input: [ 'resources/css/app.css', 'resources/js/app.js', ], refresh: true, }), ],});{{-- resources/views/layouts/app.blade.php --}}<!DOCTYPE html><html><head> @vite(['resources/css/app.css', 'resources/js/app.js'])</head><body> {{-- Content --}}</body></html>Laravel Mix (Legacy)
Section titled “Laravel Mix (Legacy)”# Install Mixnpm install
# Developmentnpm run dev
# Production with versioningnpm run productionconst mix = require('laravel-mix');
mix.js('resources/js/app.js', 'public/js') .sass('resources/sass/app.scss', 'public/css') .version(); // Adds hash for cache busting{{-- In Blade templates --}}<link rel="stylesheet" href="{{ mix('css/app.css') }}"><script src="{{ mix('js/app.js') }}"></script>Asset Versioning for Cache Busting
Section titled “Asset Versioning for Cache Busting”<?php# filename: config/app.php (excerpt)// Laravel automatically handles asset versioning with Vite// For Mix, use mix() helper which adds hash to filename
// In Blade:<link rel="stylesheet" href="{{ mix('css/app.css') }}">// Outputs: /css/app.css?id=abc123 (hash changes on rebuild)Production Asset Optimization
Section titled “Production Asset Optimization”# Build for productionnpm run build
# This will:# - Minify CSS and JavaScript# - Remove comments# - Tree-shake unused code# - Generate source maps (optional)# - Add version hashesKey Differences from Rails:
- Laravel uses Vite (faster) or Mix (Webpack wrapper)
- No need for separate asset pipeline configuration
- Built-in versioning with
mix()helper - Hot module replacement in development
Logging and Monitoring
Section titled “Logging and Monitoring”Rails Logging
Section titled “Rails Logging”config.log_level = :infoconfig.log_formatter = ::Logger::Formatter.new
# Log rotationconfig.logger = ActiveSupport::Logger.new( Rails.root.join('log', 'production.log'), 1, 50.megabytes)
# UsageRails.logger.info "User #{user.id} logged in"Rails.logger.error "Payment failed: #{error.message}"Laravel Logging
Section titled “Laravel Logging”<?phpreturn [ 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['single', 'slack'], ],
'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => 'debug', ],
'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'level' => 'error', ], ],];<?php# filename: app/Http/Controllers/AuthController.php (example usage)use Illuminate\Support\Facades\Log;
// Usage examplesLog::info('User logged in', ['user_id' => $user->id]);Log::error('Payment failed', ['error' => $exception->getMessage()]);Log::channel('slack')->critical('Database connection lost');Laravel’s logging is more flexible:
- Multiple channels
- Stack multiple drivers
- Slack/email notifications built-in
- Context-aware logging
Performance Monitoring
Section titled “Performance Monitoring”Rails (New Relic / Skylight)
Section titled “Rails (New Relic / Skylight)”# Gemfilegem 'newrelic_rpm'gem 'skylight'
# config/newrelic.ymlproduction: app_name: My App license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %> monitor_mode: trueLaravel (Laravel Telescope / Horizon)
Section titled “Laravel (Laravel Telescope / Horizon)”# Install Telescope (development monitoring)composer require laravel/telescope --devphp artisan telescope:installphp artisan migrate
# Install Horizon (queue monitoring)composer require laravel/horizonphp artisan horizon:installLaravel Telescope provides incredible insights:
- Request timings
- Database queries
- Cache operations
- Queue jobs
- Exceptions
- Logs
- All built-in!
Queue Workers
Section titled “Queue Workers”Rails (Sidekiq)
Section titled “Rails (Sidekiq)”# Gemfilegem 'sidekiq'
class SendEmailWorker include Sidekiq::Worker
def perform(user_id) user = User.find(user_id) UserMailer.welcome(user).deliver_now endend
# EnqueueSendEmailWorker.perform_async(user.id)
# Start workerbundle exec sidekiqLaravel (Built-in Queues)
Section titled “Laravel (Built-in Queues)”<?phpnamespace App\Jobs;
use App\Mail\Welcome;use App\Models\User;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Support\Facades\Mail;
class SendEmail implements ShouldQueue{ use Queueable;
public function __construct( public User $user ) {}
public function handle(): void { Mail::to($this->user)->send(new Welcome($this->user)); }}
// EnqueueSendEmail::dispatch($user);
// Or delaySendEmail::dispatch($user)->delay(now()->addMinutes(10));
// Start workerphp artisan queue:workLaravel queues are built-in! No Sidekiq or Redis required (though supported).
Scheduled Tasks
Section titled “Scheduled Tasks”Rails (whenever gem)
Section titled “Rails (whenever gem)”# Gemfilegem 'whenever', require: false
every 1.day, at: '4:30 am' do runner "User.send_daily_digest"end
every :hour do rake "cleanup:old_sessions"end
# Generate crontabwhenever --update-crontabLaravel (Built-in Task Scheduling)
Section titled “Laravel (Built-in Task Scheduling)”<?phpnamespace App\Console;
use App\Jobs\ProcessPodcast;use Illuminate\Console\Scheduling\Schedule;use Illuminate\Foundation\Console\Kernel as ConsoleKernel;use Illuminate\Support\Facades\Log;
class Kernel extends ConsoleKernel{ protected function schedule(Schedule $schedule): void { $schedule->command('users:send-digest') ->dailyAt('04:30');
$schedule->command('cleanup:old-sessions') ->hourly();
$schedule->job(new ProcessPodcast) ->everyMinute() ->withoutOverlapping();
$schedule->command('backup:run') ->daily() ->onFailure(fn() => Log::error('Backup failed')); }}# Single cron entry (add to crontab)# crontab -e* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1Laravel’s scheduler is built-in and more powerful!
Complete Deployment Checklist
Section titled “Complete Deployment Checklist”Pre-Deployment
Section titled “Pre-Deployment”- All tests passing
- Database migrations tested
- Environment variables configured
- Assets compiled
- Dependencies updated
- Security checks passed
Deployment
Section titled “Deployment”# Laravel deployment steps
# 1. Enable maintenance modephp artisan down
# 2. Pull latest codegit pull origin main
# 3. Install dependenciescomposer install --no-dev --optimize-autoloader
# 4. Migrate databasephp artisan migrate --force
# 5. Clear and cache configphp artisan config:cachephp artisan route:cachephp artisan view:cache
# 6. Restart queue workersphp artisan queue:restart
# 7. Disable maintenance modephp artisan up
# 8. Monitor logstail -f storage/logs/laravel.logPost-Deployment
Section titled “Post-Deployment”- Verify application is running
- Check error logs
- Monitor performance metrics
- Test critical features
- Verify scheduled tasks
- Check queue workers
::: tip Pro Tip: Zero-Downtime Deployment Script Create a deployment script that ensures zero downtime:
#!/bin/bash# deploy.sh - Zero-downtime Laravel deployment
set -e # Exit on any error
echo "🚀 Starting deployment..."
# Pull latest codegit pull origin main
# Install dependenciescomposer install --no-dev --optimize-autoloader --no-interaction
# Run migrations (non-blocking)php artisan migrate --force
# Clear old cachesphp artisan cache:clearphp artisan view:clear
# Rebuild cachesphp artisan config:cachephp artisan route:cachephp artisan view:cache
# Restart queue workersphp artisan queue:restart
# Reload PHP-FPM (zero downtime!)sudo systemctl reload php8.4-fpm
echo "✅ Deployment complete!"
# Health checkcurl -f http://localhost/health || echo "⚠️ Health check failed!"Make it executable:
chmod +x deploy.sh./deploy.sh:::
::: warning Common Deployment Gotchas Issue #1: Cache Config Breaks Environment Variables
# ❌ PROBLEM: After running config:cache, .env changes ignoredphp artisan config:cache
# Why: Config is cached, .env not read anymore
# ✅ SOLUTION: Clear cache when changing .envphp artisan config:clear# Then update .env# Then cache againphp artisan config:cacheIssue #2: Migrations Fail in Production
# ❌ PROBLEM: Migration works locally, fails in productionphp artisan migrate# Error: Syntax error near...
# Why: Different MySQL/PostgreSQL versions
# ✅ SOLUTION: Test migrations on production-like database first# Use Laravel's databaseRefresher in testsIssue #3: File Permissions
# ❌ PROBLEM: 500 errors after deployment# storage/logs/laravel.log: Permission denied
# ✅ SOLUTION: Fix permissionssudo chown -R www-data:www-data storage bootstrap/cachesudo chmod -R 775 storage bootstrap/cacheIssue #4: Queue Workers Not Processing
# ❌ PROBLEM: Jobs stuck in queue after deployment
# Why: Old workers still running with old code
# ✅ SOLUTION: Always restart workers after deployphp artisan queue:restart
# Or use Supervisor to auto-restart# /etc/supervisor/conf.d/laravel-worker.conf[program:laravel-worker]command=php /path/to/artisan queue:work --sleep=3 --tries=3autostart=trueautorestart=trueIssue #5: CSS/JS Not Updating
# ❌ PROBLEM: Users see old CSS after deployment
# Why: Browser caching + no asset versioning
# ✅ SOLUTION: Version assets with Laravel Mix// webpack.mix.jsmix.js('resources/js/app.js', 'public/js') .sass('resources/sass/app.scss', 'public/css') .version(); // Adds hash to filename
// In Blade:<link rel="stylesheet" href="{{ mix('css/app.css') }}">Issue #6: Application Key Missing
# ❌ PROBLEM: "No application encryption key has been specified"
# ✅ SOLUTION: Generate key in productionphp artisan key:generate
# Or copy from .env.example and generatecp .env.example .envphp artisan key:generate:::
Troubleshooting Production Issues
Section titled “Troubleshooting Production Issues”Debug Mode in Production
Section titled “Debug Mode in Production”Never enable debug in production!
# ❌ NEVER DO THIS IN PRODUCTIONAPP_DEBUG=true # Exposes sensitive info!<?php# filename: config/logging.php (excerpt)// ✅ Instead, log errors properly'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['single', 'slack'], // Alert on errors ],],Finding the Root Cause
Section titled “Finding the Root Cause”# Check Laravel logstail -f storage/logs/laravel.log
# Check web server logstail -f /var/log/nginx/error.log
# Check PHP-FPM logstail -f /var/log/php8.4-fpm.log
# Check system logstail -f /var/log/syslogPerformance Debugging
Section titled “Performance Debugging”# Enable query logging temporarilyDB::enableQueryLog();// Your codedd(DB::getQueryLog());
# Use Laravel Telescope in stagingcomposer require laravel/telescope --devphp artisan telescope:installphp artisan migrate
# Use Laravel Debugbar in developmentcomposer require barryvdh/laravel-debugbar --devWrap-up
Section titled “Wrap-up”Congratulations! You’ve completed a comprehensive overview of testing, deployment, and DevOps in Laravel. Here’s what you’ve accomplished:
- ✅ Understood testing frameworks - Compared PHPUnit and Pest to RSpec
- ✅ Written comprehensive tests - Unit tests, feature tests, and database tests
- ✅ Set up test factories - Created reusable test data generators
- ✅ Configured CI/CD - Built GitHub Actions workflows
- ✅ Explored deployment options - Forge, Envoy, and manual deployments
- ✅ Implemented zero-downtime strategies - Learned deployment best practices
- ✅ Monitored applications - Used Laravel Telescope and logging
Key Takeaways
Section titled “Key Takeaways”- Testing is Similar - PHPUnit feels like RSpec, Pest even more so
- Pest is Amazing - Ruby-like testing syntax for PHP
- Built-in Tools - Queues, scheduling, logging all included
- Forge Simplifies Deployment - Much easier than managing Rails servers
- Laravel Telescope - Incredible debugging and monitoring tool
- Zero-Downtime Easy - Octane and FPM reloads are seamless
- Better Mocking - Facade faking is cleaner than Rails mocking
You now have the knowledge to test and deploy Laravel applications professionally. The next chapter will explore Laravel’s rich ecosystem of packages and community resources.
Practice Exercises
Section titled “Practice Exercises”Exercise 1: Write Comprehensive Tests
Section titled “Exercise 1: Write Comprehensive Tests”Create tests for a blog application:
- Unit tests for models
- Feature tests for CRUD operations
- Authentication tests
- API endpoint tests
Exercise 2: Set Up CI/CD
Section titled “Exercise 2: Set Up CI/CD”Configure GitHub Actions:
- Run tests on push
- Deploy to staging on merge to main
- Deploy to production on tag
Exercise 3: Deploy to Production
Section titled “Exercise 3: Deploy to Production”Deploy a Laravel app:
- Set up a server (DigitalOcean/AWS)
- Configure environment
- Set up database
- Deploy with Envoy or Forge
- Configure SSL
- Set up monitoring
Further Reading
Section titled “Further Reading”- Laravel Testing Documentation - Official Laravel testing guide
- PHPUnit Documentation - Complete PHPUnit reference
- Pest PHP Documentation - Modern PHP testing framework
- Laravel Deployment Documentation - Official deployment guide
- Laravel Forge Documentation - Managed hosting platform
- GitHub Actions Documentation - CI/CD workflows
- Laravel Telescope Documentation - Application debugging tool
- Laravel Horizon Documentation - Queue monitoring dashboard
What’s Next?
Section titled “What’s Next?”Now that you know how to test and deploy Laravel applications, the next chapter explores Laravel’s ecosystem, popular packages, and community resources.
::: tip Continue Learning Move on to Chapter 08: Ecosystem, Community, Packages to discover Laravel’s rich package ecosystem. :::