Advanced Laravel — 3-Day Hands-On Tutorial

Before You Start

Requirements:

  • PHP 8.2+
  • Composer
  • MySQL (or SQLite for quick local work)
  • Node.js + npm (for frontend assets)
  • Basic familiarity with Laravel (MVC, routing, Blade, migrations) Setup — create the project we'll use throughout all 3 days:
composer create-project laravel/laravel advanced-laravel-course
cd advanced-laravel-course
php artisan serve

Configure your .env for MySQL:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=advanced_laravel
DB_USERNAME=root
DB_PASSWORD=

Create the database, then run:

php artisan migrate

We'll build a single running example across the whole course: a Blog/Task Management API with posts, categories, and users — refactoring and extending it session by session so every day's work builds on the last.


DAY 1 — Advanced Architecture & Code Design

Session 1: Laravel Architecture Deep Dive

Step 1 — Understand the Request Lifecycle

Every Laravel request flows through:

public/index.php
  → bootstrap/app.php (creates the Application, the service container)
  → HTTP Kernel (App\Http\Kernel)
  → Global middleware
  → Route matching
  → Route middleware
  → Controller / Closure
  → Response sent back through middleware

Look at public/index.php in your project — this is the single entry point for every HTTP request.

Step 2 — Explore the Service Container

The service container is Laravel's dependency injection engine. Try this in routes/web.php:

use Illuminate\Support\Facades\Route;

Route::get('/container-demo', function () {
    // Laravel resolves this automatically
    $router = app(\Illuminate\Routing\Router::class);
    return get_class($router);
});

Step 3 — Bind Your Own Interface

Create an interface and implementation:

mkdir -p app/Contracts
// app/Contracts/PaymentGatewayInterface.php
<?php
namespace App\Contracts;

interface PaymentGatewayInterface
{
    public function charge(int $amountInCents): bool;
}
// app/Services/StripeGateway.php
<?php
namespace App\Services;

use App\Contracts\PaymentGatewayInterface;

class StripeGateway implements PaymentGatewayInterface
{
    public function charge(int $amountInCents): bool
    {
        // pretend to call Stripe
        return true;
    }
}

Step 4 — Register the Binding in a Service Provider

php artisan make:provider PaymentServiceProvider
// app/Providers/PaymentServiceProvider.php
<?php
namespace App\Providers;

use App\Contracts\PaymentGatewayInterface;
use App\Services\StripeGateway;
use Illuminate\Support\ServiceProvider;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);
    }
}

Register it in bootstrap/providers.php (Laravel 11+) or config/app.php (older versions):

return [
    App\Providers\AppServiceProvider::class,
    App\Providers\PaymentServiceProvider::class,
];

Now anywhere you type-hint PaymentGatewayInterface, Laravel injects StripeGateway automatically:

Route::get('/pay', function (PaymentGatewayInterface $gateway) {
    return $gateway->charge(1000) ? 'Paid!' : 'Failed';
});

Step 5 — Facades vs Dependency Injection

A facade is a static-looking proxy to a container binding:

// Facade style
use Illuminate\Support\Facades\Cache;
Cache::put('key', 'value', 60);

// Equivalent DI style
public function store(\Illuminate\Contracts\Cache\Repository $cache)
{
    $cache->put('key', 'value', 60);
}

Rule of thumb: use DI in classes you unit test in isolation (services, actions); facades are fine in controllers and quick scripts.

Checkpoint: you should now understand how a request enters Laravel, how the container resolves classes, and how to bind your own interfaces.


Session 2: Advanced Eloquent ORM

Step 1 — Create the Models & Migrations

php artisan make:model Category -m
php artisan make:model Post -m
// database/migrations/xxxx_create_categories_table.php
public function up(): void
{
    Schema::create('categories', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->timestamps();
    });
}
// database/migrations/xxxx_create_posts_table.php
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->foreignId('category_id')->constrained()->cascadeOnDelete();
        $table->string('title');
        $table->text('body');
        $table->boolean('is_published')->default(false);
        $table->timestamps();
    });
}
php artisan migrate

Step 2 — Define Relationships

// app/Models/Post.php
public function user()
{
    return $this->belongsTo(User::class);
}

public function category()
{
    return $this->belongsTo(Category::class);
}
// app/Models/Category.php
public function posts()
{
    return $this->hasMany(Post::class);
}
// app/Models/User.php
public function posts()
{
    return $this->hasMany(Post::class);
}

Step 3 — Eager Loading vs Lazy Loading

Lazy loading (N+1 problem):

$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name; // fires a query PER post
}

Eager loading (fixed):

$posts = Post::with('user', 'category')->get(); // 3 queries total, not N+1

Force-detect N+1 bugs during development:

// app/Providers/AppServiceProvider.php (boot method)
Model::preventLazyLoading(! app()->isProduction());

Step 4 — Query Scopes

Local scope:

// app/Models/Post.php
public function scopePublished($query)
{
    return $query->where('is_published', true);
}
Post::published()->get();

Global scope:

// app/Models/Scopes/PublishedScope.php
<?php
namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('is_published', true);
    }
}
// app/Models/Post.php
protected static function booted(): void
{
    static::addGlobalScope(new PublishedScope);
}

Step 5 — Accessors, Mutators & Custom Casts

use Illuminate\Database\Eloquent\Casts\Attribute;

// Accessor: transform on read
protected function title(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => ucfirst($value),
    );
}

// Mutator: transform on write
protected function body(): Attribute
{
    return Attribute::make(
        set: fn (string $value) => strip_tags($value),
    );
}

Custom cast (e.g. store money safely as an object):

php artisan make:cast Money
// app/Casts/Money.php
<?php
namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class Money implements CastsAttributes
{
    public function get($model, $key, $value, $attributes)
    {
        return $value / 100; // cents to dollars
    }

    public function set($model, $key, $value, $attributes)
    {
        return (int) round($value * 100); // dollars to cents
    }
}
protected $casts = [
    'price' => Money::class,
];

Checkpoint: you now have a Post/Category/User model set with real relationships, scopes, accessors, and a custom cast.


Session 3: Repository & Service Layer Pattern

Step 1 — Why Use a Repository

Repositories decouple your business logic from Eloquent, making code easier to test and swap data sources later.

Step 2 — Create the Repository Contract & Implementation

mkdir -p app/Repositories/Contracts
// app/Repositories/Contracts/PostRepositoryInterface.php
<?php
namespace App\Repositories\Contracts;

interface PostRepositoryInterface
{
    public function all();
    public function find(int $id);
    public function create(array $data);
    public function update(int $id, array $data);
    public function delete(int $id): bool;
}
// app/Repositories/PostRepository.php
<?php
namespace App\Repositories;

use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;

class PostRepository implements PostRepositoryInterface
{
    public function all()
    {
        return Post::with('user', 'category')->latest()->get();
    }

    public function find(int $id)
    {
        return Post::with('user', 'category')->findOrFail($id);
    }

    public function create(array $data)
    {
        return Post::create($data);
    }

    public function update(int $id, array $data)
    {
        $post = Post::findOrFail($id);
        $post->update($data);
        return $post;
    }

    public function delete(int $id): bool
    {
        return (bool) Post::destroy($id);
    }
}

Bind it in a provider:

// app/Providers/RepositoryServiceProvider.php
public function register(): void
{
    $this->app->bind(
        \App\Repositories\Contracts\PostRepositoryInterface::class,
        \App\Repositories\PostRepository::class
    );
}

Step 3 — Add a Service Layer for Business Logic

The repository handles data access; the service handles business rules.

// app/Services/PostService.php
<?php
namespace App\Services;

use App\Repositories\Contracts\PostRepositoryInterface;

class PostService
{
    public function __construct(
        protected PostRepositoryInterface $posts
    ) {}

    public function createPost(array $data, int $userId)
    {
        $data['user_id'] = $userId;
        $data['is_published'] = $data['is_published'] ?? false;

        return $this->posts->create($data);
    }

    public function publish(int $postId)
    {
        return $this->posts->update($postId, ['is_published' => true]);
    }
}

Step 4 — Clean Controller

php artisan make:controller PostController
// app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;

use App\Services\PostService;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function __construct(
        protected PostRepositoryInterface $posts,
        protected PostService $postService
    ) {}

    public function index()
    {
        return $this->posts->all();
    }

    public function store(Request $request)
    {
        $post = $this->postService->createPost(
            $request->only(['title', 'body', 'category_id']),
            $request->user()->id
        );

        return response()->json($post, 201);
    }
}

Notice: the controller has zero Eloquent calls. It only orchestrates.

Checkpoint: business logic (Service) is separated from data access (Repository), and the Controller is a thin coordinator.


Session 4: Form Requests & Validation

Step 1 — Generate a Form Request

php artisan make:request StorePostRequest

Step 2 — Define Rules

// app/Http/Requests/StorePostRequest.php
<?php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // or add policy checks
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:20'],
            'category_id' => ['required', 'exists:categories,id'],
            'is_published' => ['sometimes', 'boolean'],
        ];
    }

    public function messages(): array
    {
        return [
            'category_id.exists' => 'The selected category does not exist.',
        ];
    }
}

Step 3 — Custom Validation Logic (Rule Object)

php artisan make:rule NoBannedWords
// app/Rules/NoBannedWords.php
<?php
namespace App\Rules;

use Illuminate\Contracts\Validation\ValidationRule;
use Closure;

class NoBannedWords implements ValidationRule
{
    protected array $banned = ['spam', 'scam'];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        foreach ($this->banned as $word) {
            if (str_contains(strtolower($value), $word)) {
                $fail("The {$attribute} contains a banned word: {$word}");
            }
        }
    }
}
'title' => ['required', 'string', 'max:255', new NoBannedWords],

Step 4 — Request Sanitization

protected function prepareForValidation(): void
{
    $this->merge([
        'title' => strip_tags($this->title),
    ]);
}

Step 5 — Use It in the Controller

public function store(StorePostRequest $request)
{
    $post = $this->postService->createPost(
        $request->validated(),
        $request->user()->id
    );

    return response()->json($post, 201);
}

Checkpoint: invalid input never reaches your service layer.


Day 1 Hands-On Lab: Refactor a CRUD App

Goal: take a "fat controller" CRUD and refactor it into the repository + service pattern.

  1. Start from a plain controller that does Post::create(), Post::find(), etc. directly.
  2. Extract an interface + repository (Step 2/3 above).
  3. Bind the interface in a provider.
  4. Move business rules (e.g. "only publish if body length > 100") into a PostService.
  5. Replace inline $request->validate([...]) with a dedicated FormRequest.
  6. Re-test every endpoint with php artisan route:list and Postman/Insomnia to confirm behavior is unchanged. Deliverable: a PostController with only 4–6 lines per method, no direct Eloquent calls, and full validation via Form Requests.

DAY 2 — APIs, Security & Async Processing

Session 1: Building RESTful APIs

Step 1 — Set Up API Routes

php artisan install:api

This adds routes/api.php and Sanctum. Define resourceful routes:

// routes/api.php
use App\Http\Controllers\Api\V1\PostController;

Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
});

Step 2 — Generate a Resource Controller

php artisan make:controller Api/V1/PostController --api --model=Post

This scaffolds index, store, show, update, destroy — no create/edit (those are view-only, irrelevant for APIs).

Step 3 — API Versioning Strategy

Namespace controllers under Api/V1, Api/V2, and prefix routes accordingly (/api/v1/...). When you need breaking changes, add Api/V2 alongside — never break V1 clients.

Route::prefix('v2')->group(function () {
    Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});

Step 4 — JSON Responses with API Resources (Transformers)

php artisan make:resource PostResource
php artisan make:resource PostCollection
// app/Http/Resources/PostResource.php
<?php
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'body' => $this->body,
            'is_published' => $this->is_published,
            'author' => $this->whenLoaded('user', fn () => $this->user->name),
            'category' => $this->whenLoaded('category', fn () => $this->category->name),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
// app/Http/Controllers/Api/V1/PostController.php
public function index()
{
    return PostResource::collection(
        Post::with('user', 'category')->paginate(15)
    );
}

public function show(Post $post)
{
    return new PostResource($post->load('user', 'category'));
}

Checkpoint: hitting GET /api/v1/posts returns clean, versioned, transformed JSON — never raw Eloquent models.


Session 2: Authentication & Authorization

Step 1 — API Authentication with Sanctum

Sanctum is already installed from php artisan install:api. Add the trait to User:

// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
}

Issue a token on login:

// routes/api.php
Route::post('/login', function (Request $request) {
    $request->validate(['email' => 'required|email', 'password' => 'required']);

    $user = \App\Models\User::where('email', $request->email)->first();

    if (! $user || ! \Illuminate\Support\Facades\Hash::check($request->password, $user->password)) {
        return response()->json(['message' => 'Invalid credentials'], 401);
    }

    return response()->json([
        'token' => $user->createToken('api-token')->plainTextToken,
    ]);
});

Protect routes:

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('posts', PostController::class);
});

Clients send: Authorization: Bearer {token}

Step 2 — Role-Based Access Control (RBAC)

Add a role column:

php artisan make:migration add_role_to_users_table --table=users
$table->string('role')->default('author'); // author, editor, admin

Step 3 — Policies & Gates

php artisan make:policy PostPolicy --model=Post
// app/Policies/PostPolicy.php
<?php
namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id || $user->role === 'admin';
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->role === 'admin';
    }
}

Use it in the controller:

public function update(StorePostRequest $request, Post $post)
{
    $this->authorize('update', $post);

    $post->update($request->validated());
    return new PostResource($post);
}

Or a simple Gate for one-off checks:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

Gate::define('access-admin-panel', function ($user) {
    return $user->role === 'admin';
});
if (Gate::allows('access-admin-panel')) {
    // ...
}

Checkpoint: unauthenticated requests get 401, unauthorized ones get 403, and only the post owner or an admin can edit/delete.


Session 3: Queues & Background Jobs

Step 1 — Configure a Queue Driver

QUEUE_CONNECTION=database
php artisan queue:table
php artisan migrate

(Use QUEUE_CONNECTION=redis in production for better throughput.)

Step 2 — Create a Job

php artisan make:job SendPostPublishedEmail
// app/Jobs/SendPostPublishedEmail.php
<?php
namespace App\Jobs;

use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class SendPostPublishedEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 30; // seconds between retries

    public function __construct(public Post $post) {}

    public function handle(): void
    {
        Mail::raw("Your post '{$this->post->title}' was published!", function ($message) {
            $message->to($this->post->user->email)
                     ->subject('Post Published');
        });
    }

    public function failed(\Throwable $exception): void
    {
        // log, alert, etc.
    }
}

Step 3 — Dispatch the Job

// app/Services/PostService.php
public function publish(int $postId)
{
    $post = $this->posts->update($postId, ['is_published' => true]);
    \App\Jobs\SendPostPublishedEmail::dispatch($post);
    return $post;
}

Step 4 — Run the Worker

php artisan queue:work --tries=3

Step 5 — Handling Failed Jobs

php artisan queue:failed
php artisan queue:retry all
php artisan queue:retry {id}

Checkpoint: publishing a post no longer blocks the HTTP response waiting on email delivery — it's handled asynchronously.


Session 4: Events & Listeners

Step 1 — Generate Event & Listener

php artisan make:event PostPublished
php artisan make:listener SendPostPublishedNotification --event=PostPublished

Step 2 — Define the Event

// app/Events/PostPublished.php
<?php
namespace App\Events;

use App\Models\Post;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class PostPublished
{
    use Dispatchable, SerializesModels;

    public function __construct(public Post $post) {}
}

Step 3 — Define the Listener (queued)

// app/Listeners/SendPostPublishedNotification.php
<?php
namespace App\Listeners;

use App\Events\PostPublished;
use App\Jobs\SendPostPublishedEmail;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendPostPublishedNotification implements ShouldQueue
{
    public function handle(PostPublished $event): void
    {
        SendPostPublishedEmail::dispatch($event->post);
    }
}

Step 4 — Fire the Event Instead of Calling the Job Directly

// app/Services/PostService.php
public function publish(int $postId)
{
    $post = $this->posts->update($postId, ['is_published' => true]);
    \App\Events\PostPublished::dispatch($post);
    return $post;
}

This decouples "publishing a post" from "notifying people" — you can add more listeners (Slack alert, cache invalidation, analytics) without touching PostService again.

Real-world use case: an e-commerce OrderPlaced event might trigger: send confirmation email, notify warehouse, update analytics, award loyalty points — 4 listeners, 1 event, 0 coupling.

Checkpoint: PostService only knows about the event, not who's listening or what happens next.


Day 2 Hands-On Lab: REST API with Auth + Background Job

Goal: a working, authenticated API where publishing triggers an async email.

  1. Confirm POST /api/v1/login returns a Sanctum token.
  2. Confirm GET /api/v1/posts returns 401 without a token, and data with one.
  3. Add a PATCH /api/v1/posts/{id}/publish endpoint guarded by the PostPolicy.
  4. Publishing should dispatch PostPublished, which queues SendPostPublishedEmail.
  5. Run php artisan queue:work and confirm the email log entry appears (use MAIL_MAILER=log in .env for local testing).
  6. Test a failed-job scenario by throwing an exception in the job temporarily, then check php artisan queue:failed. Deliverable: a protected, versioned REST API where a business action (publishing) triggers a decoupled, queued side effect.

DAY 3 — Performance, Testing & Deployment

Session 1: Performance Optimization

Step 1 — Caching Strategies

CACHE_STORE=redis
use Illuminate\Support\Facades\Cache;

// Cache-aside pattern
$categories = Cache::remember('categories.all', now()->addHours(6), function () {
    return \App\Models\Category::all();
});

Invalidate on write:

// app/Observers/CategoryObserver.php
public function saved(Category $category): void
{
    Cache::forget('categories.all');
}

Register the observer in a provider:

// app/Providers/AppServiceProvider.php
Category::observe(\App\Observers\CategoryObserver::class);

Step 2 — Install & Use Redis

composer require predis/predis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Use Redis directly for counters, rate limiting, etc.:

use Illuminate\Support\Facades\Redis;

Redis::incr('post:' . $post->id . ':views');

Step 3 — Optimizing Database Queries

composer require barryvdh/laravel-debugbar --dev

Common fixes:

  • Add indexes on foreign keys and frequently filtered columns:

    $table->foreignId('category_id')->constrained()->index();
    
  • Select only needed columns:

    Post::select('id', 'title', 'user_id')->get();
    
  • Use chunk() for large datasets instead of loading everything:

    Post::where('is_published', true)->chunk(200, function ($posts) {
      foreach ($posts as $post) {
          // process
      }
    });
    

Step 4 — Laravel Octane (Intro)

composer require laravel/octane
php artisan octane:install --server=swoole
php artisan octane:start

Octane keeps your app booted in memory between requests (via Swoole/RoadRunner) instead of bootstrapping Laravel from scratch every time — dramatically higher throughput. Caveat: static state and singletons persist across requests, so audit for memory leaks and shared state bugs before adopting it in production.

Checkpoint: the categories endpoint is cache-backed, indexes exist on your foreign keys, and you understand what Octane trades off for speed.


Session 2: Testing in Laravel

Step 1 — Unit vs Feature Tests

php artisan make:test PostServiceTest --unit
php artisan make:test PostApiTest

Unit test (tests one class in isolation, mocking dependencies):

// tests/Unit/PostServiceTest.php
<?php
namespace Tests\Unit;

use App\Repositories\Contracts\PostRepositoryInterface;
use App\Services\PostService;
use Tests\TestCase;
use Mockery;

class PostServiceTest extends TestCase
{
    public function test_create_post_defaults_to_unpublished(): void
    {
        $repo = Mockery::mock(PostRepositoryInterface::class);
        $repo->shouldReceive('create')
             ->once()
             ->with(Mockery::on(fn ($data) => $data['is_published'] === false))
             ->andReturn((object) ['id' => 1]);

        $service = new PostService($repo);
        $service->createPost(['title' => 'Test', 'body' => 'Body'], userId: 1);
    }
}

Feature test (tests the full HTTP flow through the framework):

// tests/Feature/PostApiTest.php
<?php
namespace Tests\Feature;

use App\Models\User;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_post(): void
    {
        $user = User::factory()->create();
        $category = Category::factory()->create();

        $response = $this->actingAs($user, 'sanctum')->postJson('/api/v1/posts', [
            'title' => 'My First Post',
            'body' => str_repeat('a', 30),
            'category_id' => $category->id,
        ]);

        $response->assertStatus(201)
                  ->assertJsonPath('title', 'My First Post');

        $this->assertDatabaseHas('posts', ['title' => 'My First Post']);
    }

    public function test_guest_cannot_create_post(): void
    {
        $response = $this->postJson('/api/v1/posts', []);
        $response->assertStatus(401);
    }
}

Step 2 — Database Testing with Factories

php artisan make:factory PostFactory --model=Post
// database/factories/PostFactory.php
public function definition(): array
{
    return [
        'user_id' => \App\Models\User::factory(),
        'category_id' => \App\Models\Category::factory(),
        'title' => fake()->sentence(),
        'body' => fake()->paragraphs(3, true),
        'is_published' => false,
    ];
}

RefreshDatabase (used above) migrates a fresh test database for every test run, keeping tests isolated.

Step 3 — Mocking & Faking

Fake queue/mail/events instead of letting them actually run during tests:

use Illuminate\Support\Facades\Queue;
use App\Jobs\SendPostPublishedEmail;

public function test_publishing_dispatches_email_job(): void
{
    Queue::fake();

    // ... call the endpoint that publishes a post ...

    Queue::assertPushed(SendPostPublishedEmail::class);
}
php artisan test

Checkpoint: you have unit tests around business logic and feature tests around the HTTP surface, with queues/mail faked so tests run fast and deterministically.


Session 3: File Storage & Media Handling

Step 1 — Laravel Filesystem Basics

FILESYSTEM_DISK=local
use Illuminate\Support\Facades\Storage;

Storage::disk('local')->put('example.txt', 'Hello');

Step 2 — Local vs Cloud Storage

// config/filesystems.php — 's3' disk already scaffolded by default
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
    ],
],

Step 3 — Integration with AWS S3

composer require league/flysystem-aws-s3-v3
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_DEFAULT_REGION=ap-southeast-1
AWS_BUCKET=my-app-bucket

Step 4 — File Uploads & Security

php artisan make:migration add_image_path_to_posts_table --table=posts
$table->string('image_path')->nullable();
// app/Http/Requests/StorePostRequest.php
public function rules(): array
{
    return [
        // ...existing rules
        'image' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
    ];
}
// app/Http/Controllers/Api/V1/PostController.php
public function store(StorePostRequest $request)
{
    $data = $request->validated();

    if ($request->hasFile('image')) {
        $data['image_path'] = $request->file('image')->store('posts', 's3');
    }

    $post = $this->postService->createPost($data, $request->user()->id);
    return new PostResource($post);
}

Security notes:

  • Always validate mimes/image — never trust the client's declared content-type.
  • Never store uploads in a publicly executable directory.
  • Regenerate filenames (Laravel's store() already does this) to avoid path traversal and overwrite attacks. Checkpoint: post images upload to S3 (or local disk in dev) with type/size validation and safe filenames.

Session 4: Deployment & DevOps

Step 1 — Environment Configuration

Never commit .env. Maintain per-environment files (.env.production, secrets manager, etc.) and always run:

php artisan config:cache
php artisan route:cache
php artisan view:cache

on deploy (and clear them with php artisan optimize:clear before debugging).

Step 2 — Basic Deployment Workflow

git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart   # reload workers with new code

Step 3 — CI/CD Basics (GitHub Actions example)

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --prefer-dist --no-progress
      - run: cp .env.example .env
      - run: php artisan key:generate
      - run: php artisan test

Step 4 — Using Docker

# Dockerfile
FROM php:8.3-fpm

RUN apt-get update && apt-get install -y \
    libzip-dev unzip git \
    && docker-php-ext-install pdo_mysql zip

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www
COPY . .
RUN composer install --no-dev --optimize-autoloader

CMD ["php-fpm"]
# docker-compose.yml
services:
  app:
    build: .
    volumes:
      - .:/var/www
    depends_on:
      - db
      - redis
  nginx:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
      - .:/var/www
  db:
    image: mysql:8
    environment:
      MYSQL_DATABASE: advanced_laravel
      MYSQL_ROOT_PASSWORD: secret
  redis:
    image: redis:alpine

Step 5 — Basic Server Setup (Nginx + PHP-FPM)

# docker/nginx.conf
server {
    listen 80;
    root /var/www/public;
    index index.php;

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

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
docker compose up -d --build

Checkpoint: the app runs in containers behind Nginx, with a repeatable CI pipeline validating every push.


Day 3 / Final Project

Bring everything together into one deliverable:

  1. API backend — versioned posts/categories REST API with resource transformers.
  2. Authentication — Sanctum tokens, RBAC via role, enforced through PostPolicy.
  3. Queue processing — publishing a post fires PostPublished → queued email job.
  4. Caching enabled — categories list cached in Redis, invalidated via observer.
  5. Tested endpoints — unit tests on PostService, feature tests on the API, Queue::fake() used to isolate side effects.
  6. Containerize with Docker Compose and verify docker compose up serves the API through Nginx. Run the full suite before calling it done:
php artisan test

Learning Outcomes Recap

By completing all three days, you should now be able to:

  • Design scalable Laravel applications using the service container, repositories, and service layers
  • Build secure, versioned REST APIs with Sanctum authentication and policy-based authorization
  • Implement queues, background jobs, and event-driven side effects
  • Optimize performance using caching strategies and Redis
  • Write automated unit and feature tests, faking queues/mail for isolation
  • Deploy Laravel applications with Docker, CI/CD, and a production-ready Nginx/PHP-FPM stack