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.
- Start from a plain controller that does
Post::create(),Post::find(), etc. directly. - Extract an interface + repository (Step 2/3 above).
- Bind the interface in a provider.
- Move business rules (e.g. "only publish if body length > 100") into a
PostService. - Replace inline
$request->validate([...])with a dedicatedFormRequest. - Re-test every endpoint with
php artisan route:listand Postman/Insomnia to confirm behavior is unchanged. Deliverable: aPostControllerwith 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.
- Confirm
POST /api/v1/loginreturns a Sanctum token. - Confirm
GET /api/v1/postsreturns401without a token, and data with one. - Add a
PATCH /api/v1/posts/{id}/publishendpoint guarded by thePostPolicy. - Publishing should dispatch
PostPublished, which queuesSendPostPublishedEmail. - Run
php artisan queue:workand confirm the email log entry appears (useMAIL_MAILER=login.envfor local testing). - 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:
- API backend — versioned
posts/categoriesREST API with resource transformers. - Authentication — Sanctum tokens, RBAC via
role, enforced throughPostPolicy. - Queue processing — publishing a post fires
PostPublished→ queued email job. - Caching enabled — categories list cached in Redis, invalidated via observer.
- Tested endpoints — unit tests on
PostService, feature tests on the API,Queue::fake()used to isolate side effects. - Containerize with Docker Compose and verify
docker compose upserves 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