Skip to content

07: Testing, Deployment, DevOps: Best Practices

Testing, Deployment, DevOps

07: Testing, Deployment, DevOps Intermediate

Section titled “07: Testing, Deployment, DevOps Intermediate”

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.

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:

Terminal window
# Check Laravel installation
php artisan --version
# Verify PHPUnit is available
./vendor/bin/phpunit --version

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

Complete testing examples and deployment patterns:

Access code samples:

Terminal window
git clone https://github.com/dalehurley/codewithphp.git
cd codewithphp/code/rails-developers-love-laravel/chapter-10
  • 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
# 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
end
<?php
# filename: composer.json (excerpt)
// Laravel comes with PHPUnit pre-configured
{
"require-dev": {
"phpunit/phpunit": "^11.0",
"mockery/mockery": "^1.6"
}
}
tests/TestCase.php
<?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.

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
end
tests/Unit/UserTest.php
<?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 @test annotation
  • Assertions are methods: $this->assertEquals(), $this->assertTrue()
  • RefreshDatabase trait handles database cleanup

Laravel also supports Pest - a modern testing framework with Ruby-like syntax:

tests/Unit/UserTest.php
<?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.

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
end
tests/Feature/PostManagementTest.php
<?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()
tests/Feature/PostManagementTest.php
<?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!

Laravel provides powerful HTTP testing assertions that make API testing straightforward. Here are the most commonly used assertions:

tests/Feature/ApiPostTest.php
<?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']);
}
}
tests/Feature/ApiResponseTest.php
<?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();
}
<?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_response helpers)
  • Session and cookie assertions are more comprehensive
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
end
tests/Feature/PostTest.php
<?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
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)
database/factories/UserFactory.php
<?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

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
end

Laravel:

<?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!

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
end
tests/Unit/PaymentServiceTest.php
<?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);
}
}
tests/Feature/UserRegistrationTest.php
<?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!

tests/Unit/Middleware/EnsureUserIsActiveTest.php
<?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());
}
}
tests/Feature/UserRegistrationTest.php
<?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',
]);
}
}
tests/Feature/Commands/SendDailyDigestTest.php
<?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);
}
}
tests/Feature/FileUploadTest.php
<?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']);
}
}
spec/
├── models/
├── controllers/
├── requests/
├── features/
├── support/
└── factories/
tests/
├── Unit/ # Unit tests
├── Feature/ # Integration tests
├── Browser/ # Dusk browser tests (optional)
└── TestCase.php # Base test class

Simpler structure, clear separation.

Laravel Dusk is Laravel’s browser testing tool, similar to Capybara in Rails. It uses ChromeDriver and provides a clean API for browser automation.

Terminal window
# Install Dusk
composer require --dev laravel/dusk
# Install Dusk
php artisan dusk:install
tests/Browser/PostManagementTest.php
<?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)

Section titled “Dusk Page Objects (Like Capybara Page Objects)”
tests/Browser/Pages/PostCreatePage.php
<?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');
});
}
<?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
Terminal window
# 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/
Terminal window
# 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 --parallel

Laravel’s php artisan test provides prettier output than PHPUnit!

.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 rspec
.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=80

Nearly identical workflow structure!

# 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 deploy
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
Terminal window
# Deploy using Envoy
php vendor/bin/envoy run deploy

Both use similar task-based deployment strategies.

Laravel Forge is like Heroku for Laravel (but better):

  • ✅ 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
Terminal window
# 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 main

Forge is significantly easier than managing Rails servers!

Docker provides a consistent environment across development, staging, and production, similar to how Rails developers use Docker.

# 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.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: bridge
Terminal window
# 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 --build
Terminal window
# 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 -d

Laravel Sail is Laravel’s official Docker development environment:

Terminal window
# 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 test

Key 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)
config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection
end
# Phased restart
pumactl phased-restart
Terminal window
# 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 --swoole

Laravel Octane provides instant reloads without downtime.

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.

  • Blue Environment: Current production (live)
  • Green Environment: New version (staging)
  • Switch traffic from Blue to Green when ready
  • Keep Blue as fallback
Terminal window
# 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 deployment

Forge supports blue-green deployments:

Terminal window
# 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 deployment
Terminal window
# 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 rollback
blue-green-deploy.sh
#!/bin/bash
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
fi

Benefits:

  • Zero downtime deployments
  • Easy rollback (switch back to previous environment)
  • Test new version before going live
  • Reduced risk
Terminal window
# 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:status
Terminal window
# 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 --force

Best Practice (Both Frameworks):

  1. Test migrations locally first
  2. Backup database before migration
  3. Make migrations reversible
  4. Deploy migrations separately from code
  5. Monitor application during migration
.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=production
.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=redis

Laravel’s .env is more verbose but clearer.

Laravel uses Vite (or Laravel Mix) for asset compilation, similar to Webpacker in Rails.

Terminal window
# 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>
Terminal window
# 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>
<?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)
Terminal window
# Build for production
npm run build
# This will:
# - Minify CSS and JavaScript
# - Remove comments
# - Tree-shake unused code
# - Generate source maps (optional)
# - Add version hashes

Key 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
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}"
config/logging.php
<?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
# Gemfile
gem 'newrelic_rpm'
gem 'skylight'
# config/newrelic.yml
production:
app_name: My App
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
monitor_mode: true
Terminal window
# 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:install

Laravel Telescope provides incredible insights:

  • Request timings
  • Database queries
  • Cache operations
  • Queue jobs
  • Exceptions
  • Logs
  • All built-in!
app/workers/send_email_worker.rb
# Gemfile
gem 'sidekiq'
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 sidekiq
app/Jobs/SendEmail.php
<?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:work

Laravel queues are built-in! No Sidekiq or Redis required (though supported).

config/schedule.rb
# Gemfile
gem '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 crontab
whenever --update-crontab
app/Console/Kernel.php
<?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'));
}
}
Terminal window
# Single cron entry (add to crontab)
# crontab -e
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1

Laravel’s scheduler is built-in and more powerful!

  • All tests passing
  • Database migrations tested
  • Environment variables configured
  • Assets compiled
  • Dependencies updated
  • Security checks passed
Terminal window
# 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.log
  • 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 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:

Terminal window
chmod +x deploy.sh
./deploy.sh

:::

::: warning Common Deployment Gotchas Issue #1: Cache Config Breaks Environment Variables

Terminal window
# ❌ 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:cache

Issue #2: Migrations Fail in Production

Terminal window
# ❌ 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 tests

Issue #3: File Permissions

Terminal window
# ❌ 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/cache

Issue #4: Queue Workers Not Processing

Terminal window
# ❌ 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=true

Issue #5: CSS/JS Not Updating

Terminal window
# ❌ 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

Terminal window
# ❌ 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:generate

:::

Never enable debug in production!

.env
# ❌ NEVER DO THIS IN PRODUCTION
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
],
],
Terminal window
# 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/syslog
Terminal window
# 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 --dev

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
  1. Testing is Similar - PHPUnit feels like RSpec, Pest even more so
  2. Pest is Amazing - Ruby-like testing syntax for PHP
  3. Built-in Tools - Queues, scheduling, logging all included
  4. Forge Simplifies Deployment - Much easier than managing Rails servers
  5. Laravel Telescope - Incredible debugging and monitoring tool
  6. Zero-Downtime Easy - Octane and FPM reloads are seamless
  7. 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.

Create tests for a blog application:

  • Unit tests for models
  • Feature tests for CRUD operations
  • Authentication tests
  • API endpoint tests

Configure GitHub Actions:

  • Run tests on push
  • Deploy to staging on merge to main
  • Deploy to production on tag

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

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. :::