
07: Testing, Deployment, DevOps Intermediate
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
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 installation
php artisan --version
# Verify PHPUnit is available
./vendor/bin/phpunit --versionWhat 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
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.git
cd codewithphp/code/rails-developers-love-laravel/chapter-10Objectives
- 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
Rails Testing Setup
# Gemfile
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'faker'
end
# spec/rails_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.include FactoryBot::Syntax::Methods
endLaravel Testing Setup
<?php
# filename: composer.json (excerpt)
// Laravel comes with PHPUnit pre-configured
{
"require-dev": {
"phpunit/phpunit": "^11.0",
"mockery/mockery": "^1.6"
}
}<?php
# filename: tests/TestCase.php
namespace 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
Rails Unit Test (RSpec)
# spec/models/user_spec.rb
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
end
endLaravel Unit Test (PHPUnit)
<?php
# filename: tests/Unit/UserTest.php
namespace 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
Laravel also supports Pest - a modern testing framework with Ruby-like syntax:
<?php
# filename: tests/Unit/UserTest.php
use 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)
Rails Feature Test
# spec/features/post_management_spec.rb
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
end
endLaravel Feature Test
<?php
# filename: tests/Feature/PostManagementTest.php
namespace 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
<?php
# filename: tests/Feature/PostManagementTest.php
use 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
Laravel provides powerful HTTP testing assertions that make API testing straightforward. Here are the most commonly used assertions:
JSON Assertions
<?php
# filename: tests/Feature/ApiPostTest.php
namespace 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
<?php
# filename: tests/Feature/ApiResponseTest.php
public 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
<?php
public 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
Rails Database Strategy
# spec/rails_helper.rb
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)
end
endLaravel Database Strategy
<?php
# filename: tests/Feature/PostTest.php
namespace 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
Rails Factories (FactoryBot)
# spec/factories/users.rb
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
end
end
# Usage
user = create(:user)
admin = create(:user, :admin)
user_with_posts = create(:user_with_posts, posts_count: 3)Laravel Factories
<?php
# filename: database/factories/UserFactory.php
namespace 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
Rails:
# Create user with posts and comments
user = create(:user) do |u|
create_list(:post, 3, user: u) do |post|
create_list(:comment, 2, post: post)
end
endLaravel:
<?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
Rails Mocking (RSpec)
# spec/services/payment_service_spec.rb
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
end
endLaravel Mocking (Mockery)
<?php
# filename: tests/Unit/PaymentServiceTest.php
namespace 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)
<?php
# filename: tests/Feature/UserRegistrationTest.php
namespace 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
Testing Middleware
<?php
# filename: tests/Unit/Middleware/EnsureUserIsActiveTest.php
namespace 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
<?php
# filename: tests/Feature/UserRegistrationTest.php
namespace 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
<?php
# filename: tests/Feature/Commands/SendDailyDigestTest.php
namespace 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
<?php
# filename: tests/Feature/FileUploadTest.php
namespace 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
Rails Test Structure
spec/
├── models/
├── controllers/
├── requests/
├── features/
├── support/
└── factories/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)
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
# Install Dusk
composer require --dev laravel/dusk
# Install Dusk
php artisan dusk:installBasic Dusk Test
<?php
# filename: tests/Browser/PostManagementTest.php
namespace 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)
<?php
# filename: tests/Browser/Pages/PostCreatePage.php
namespace 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 test
public 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
<?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
Rails
# All tests
bundle exec rspec
# Specific file
bundle exec rspec spec/models/user_spec.rb
# Specific test
bundle exec rspec spec/models/user_spec.rb:12
# With coverage
COVERAGE=true bundle exec rspec
# Parallel tests
bundle exec parallel_test spec/Laravel
# All tests
php artisan test
# Or directly with PHPUnit
./vendor/bin/phpunit
# Specific file
php artisan test tests/Feature/PostTest.php
# Specific test
php artisan test --filter test_user_can_create_post
# With coverage
php artisan test --coverage
# Parallel tests
php artisan test --parallelLaravel's php artisan test provides prettier output than PHPUnit!
Continuous Integration
Rails CI (GitHub Actions)
# .github/workflows/test.yml
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)
# .github/workflows/test.yml
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
Rails Deployment (Capistrano)
# Capfile
require 'capistrano/rails'
require 'capistrano/bundler'
require 'capistrano/rbenv'
require 'capistrano/puma'
# config/deploy.rb
set :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, :restart
end
# Deploy
cap production deployLaravel Deployment (Envoy)
# filename: Envoy.blade.php
@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 Envoy
php vendor/bin/envoy run deployBoth use similar task-based deployment strategies.
Laravel Forge (Managed Hosting)
Laravel Forge is like Heroku for Laravel (but better):
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
# 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.com
git pull origin main
composer install --no-interaction --prefer-dist --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
# 4. Push to trigger deployment
git push origin mainForge is significantly easier than managing Rails servers!
Docker and Containerization
Docker provides a consistent environment across development, staging, and production, similar to how Rails developers use Docker.
Basic Docker Setup
# Dockerfile
FROM php:8.4-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip
# Install PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www
# Copy application
COPY . /var/www
# Install dependencies
RUN composer install --optimize-autoloader --no-dev
# Set permissions
RUN chown -R www-data:www-data /var/www
RUN chmod -R 755 /var/www/storage
EXPOSE 9000
CMD ["php-fpm"]Docker Compose for Development
# docker-compose.yml
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
# Build and start containers
docker-compose up -d
# Run migrations
docker-compose exec app php artisan migrate
# Run tests
docker-compose exec app php artisan test
# View logs
docker-compose logs -f app
# Stop containers
docker-compose down
# Rebuild after changes
docker-compose up -d --buildProduction Docker Deployment
# Build production image
docker build -t myapp:latest .
# Run container
docker run -d \
--name myapp \
-p 80:9000 \
-v $(pwd)/.env:/var/www/.env \
myapp:latest
# Or use docker-compose for production
docker-compose -f docker-compose.prod.yml up -dDocker with Laravel Sail
Laravel Sail is Laravel's official Docker development environment:
# Install Sail
composer require laravel/sail --dev
# Publish Sail config
php 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
Rails (Puma with Phased Restart)
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection
end
# Phased restart
pumactl phased-restartLaravel (Octane or FPM Reload)
# Option 1: Laravel Octane (recommended)
php artisan octane:reload
# Option 2: PHP-FPM reload
sudo systemctl reload php8.4-fpm
# Option 3: Swoole hot reload
php artisan octane:reload --swooleLaravel Octane provides instant reloads without downtime.
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
- 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
# 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/green
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 3. Test Green environment
curl https://production-green.example.com/health
# 4. Switch load balancer to Green
# (Update DNS or load balancer config)
# 5. Monitor Green
tail -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
Forge supports blue-green deployments:
# In Forge deployment script
cd /home/forge/green.example.com
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Switch DNS/load balancer to green
# Monitor and verify
# Then update blue for next deploymentDatabase 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
#!/bin/bash
# blue-green-deploy.sh
set -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_DIR
else
DEPLOY_TO="blue"
DEPLOY_DIR=$BLUE_DIR
fi
echo "🚀 Deploying to $DEPLOY_TO environment..."
cd $DEPLOY_DIR
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Health check
if 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 1
fiBenefits:
- Zero downtime deployments
- Easy rollback (switch back to previous environment)
- Test new version before going live
- Reduced risk
Database Migrations in Production
Rails
# Run migrations
RAILS_ENV=production bundle exec rails db:migrate
# Rollback if needed
RAILS_ENV=production bundle exec rails db:rollback
# Check migration status
RAILS_ENV=production bundle exec rails db:migrate:statusLaravel
# Run migrations
php artisan migrate --force
# Rollback last batch
php artisan migrate:rollback
# Rollback specific steps
php artisan migrate:rollback --step=3
# Check migration status
php 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
Rails (.env)
# .env.production
DATABASE_URL=postgresql://user:pass@host/db
REDIS_URL=redis://localhost:6379/0
SECRET_KEY_BASE=long_secret_key
RAILS_ENV=production
RACK_ENV=productionLaravel (.env)
# .env
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:generated_key
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=secret
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redisLaravel's .env is more verbose but clearer.
Asset Compilation and Bundling
Laravel uses Vite (or Laravel Mix) for asset compilation, similar to Webpacker in Rails.
Laravel Vite (Recommended)
# Install Vite (included in Laravel 9+)
npm install
# Development
npm run dev
# Production build
npm run build// vite.config.js
import { 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)
# Install Mix
npm install
# Development
npm run dev
# Production with versioning
npm run production// webpack.mix.js
const 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
<?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
# Build for production
npm 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
Rails Logging
# config/environments/production.rb
config.log_level = :info
config.log_formatter = ::Logger::Formatter.new
# Log rotation
config.logger = ActiveSupport::Logger.new(
Rails.root.join('log', 'production.log'),
1, 50.megabytes
)
# Usage
Rails.logger.info "User #{user.id} logged in"
Rails.logger.error "Payment failed: #{error.message}"Laravel Logging
<?php
# filename: config/logging.php
return [
'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 examples
Log::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
Rails (New Relic / Skylight)
# Gemfile
gem 'newrelic_rpm'
gem 'skylight'
# config/newrelic.yml
production:
app_name: My App
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
monitor_mode: trueLaravel (Laravel Telescope / Horizon)
# Install Telescope (development monitoring)
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
# Install Horizon (queue monitoring)
composer require laravel/horizon
php artisan horizon:installLaravel Telescope provides incredible insights:
- Request timings
- Database queries
- Cache operations
- Queue jobs
- Exceptions
- Logs
- All built-in!
Queue Workers
Rails (Sidekiq)
# Gemfile
gem 'sidekiq'
# app/workers/send_email_worker.rb
class SendEmailWorker
include Sidekiq::Worker
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
# Enqueue
SendEmailWorker.perform_async(user.id)
# Start worker
bundle exec sidekiqLaravel (Built-in Queues)
<?php
# filename: app/Jobs/SendEmail.php
namespace 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));
}
}
// Enqueue
SendEmail::dispatch($user);
// Or delay
SendEmail::dispatch($user)->delay(now()->addMinutes(10));
// Start worker
php artisan queue:workLaravel queues are built-in! No Sidekiq or Redis required (though supported).
Scheduled Tasks
Rails (whenever gem)
# Gemfile
gem 'whenever', require: false
# config/schedule.rb
every 1.day, at: '4:30 am' do
runner "User.send_daily_digest"
end
every :hour do
rake "cleanup:old_sessions"
end
# Generate crontab
whenever --update-crontabLaravel (Built-in Task Scheduling)
<?php
# filename: app/Console/Kernel.php
namespace 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
Pre-Deployment
- [ ] All tests passing
- [ ] Database migrations tested
- [ ] Environment variables configured
- [ ] Assets compiled
- [ ] Dependencies updated
- [ ] Security checks passed
Deployment
# Laravel deployment steps
# 1. Enable maintenance mode
php artisan down
# 2. Pull latest code
git pull origin main
# 3. Install dependencies
composer install --no-dev --optimize-autoloader
# 4. Migrate database
php artisan migrate --force
# 5. Clear and cache config
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 6. Restart queue workers
php artisan queue:restart
# 7. Disable maintenance mode
php artisan up
# 8. Monitor logs
tail -f storage/logs/laravel.logPost-Deployment
- [ ] Verify application is running
- [ ] Check error logs
- [ ] Monitor performance metrics
- [ ] Test critical features
- [ ] Verify scheduled tasks
- [ ] Check queue workers
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 code
git pull origin main
# Install dependencies
composer install --no-dev --optimize-autoloader --no-interaction
# Run migrations (non-blocking)
php artisan migrate --force
# Clear old caches
php artisan cache:clear
php artisan view:clear
# Rebuild caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Restart queue workers
php artisan queue:restart
# Reload PHP-FPM (zero downtime!)
sudo systemctl reload php8.4-fpm
echo "✅ Deployment complete!"
# Health check
curl -f http://localhost/health || echo "⚠️ Health check failed!"Make it executable:
chmod +x deploy.sh
./deploy.shCommon Deployment Gotchas
Issue #1: Cache Config Breaks Environment Variables
# ❌ PROBLEM: After running config:cache, .env changes ignored
php artisan config:cache
# Why: Config is cached, .env not read anymore
# ✅ SOLUTION: Clear cache when changing .env
php artisan config:clear
# Then update .env
# Then cache again
php artisan config:cacheIssue #2: Migrations Fail in Production
# ❌ PROBLEM: Migration works locally, fails in production
php 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 permissions
sudo chown -R www-data:www-data storage bootstrap/cache
sudo 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 deploy
php 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=3
autostart=true
autorestart=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.js
mix.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 production
php artisan key:generate
# Or copy from .env.example and generate
cp .env.example .env
php artisan key:generateTroubleshooting Production Issues
Debug Mode in Production
Never enable debug in production!
# ❌ NEVER DO THIS IN PRODUCTION
# .env
APP_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
# Check Laravel logs
tail -f storage/logs/laravel.log
# Check web server logs
tail -f /var/log/nginx/error.log
# Check PHP-FPM logs
tail -f /var/log/php8.4-fpm.log
# Check system logs
tail -f /var/log/syslogPerformance Debugging
# Enable query logging temporarily
DB::enableQueryLog();
// Your code
dd(DB::getQueryLog());
# Use Laravel Telescope in staging
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
# Use Laravel Debugbar in development
composer require barryvdh/laravel-debugbar --devWrap-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
- 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
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
Configure GitHub Actions:
- Run tests on push
- Deploy to staging on merge to main
- Deploy to production on tag
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
- 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?
Now that you know how to test and deploy Laravel applications, the next chapter explores Laravel's ecosystem, popular packages, and community resources.
Continue Learning
Move on to Chapter 08: Ecosystem, Community, Packages to discover Laravel's rich package ecosystem.