Advanced Laravel — Day 1: Advanced Architecture
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
composer create-project laravel/laravel advanced-laravel-coursedownloads a fresh Laravel skeleton app into a new folder calledadvanced-laravel-course. Composer is Laravel's dependency manager — this command pulls the framework itself plus its default packages.cd advanced-laravel-coursemoves your terminal into the new project folder, since every following command must run from the project root.php artisan servestarts Laravel's built-in development web server (usually onhttp://127.0.0.1:8000) so you can view the app in a browser without configuring Nginx/Apache yet.
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=
The .env file holds environment-specific configuration (never committed to git). Each line tells Laravel's database layer which driver to use (mysql), which server and port to connect to, which database/schema to use, and the credentials. Laravel reads these values through config/database.php at runtime.
Create the database, then run:
php artisan migrate
php artisan migrate executes every pending migration file in database/migrations, creating (or updating) tables in the database you configured above. Laravel tracks which migrations have already run in a migrations table, so re-running this command is safe — it only applies new ones.
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.
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
Why this matters: every advanced feature you'll build this week — middleware, service providers, policies, queues — hooks into one of these stages. Understanding the flow means you'll know where to plug in new behavior instead of guessing.
Look at public/index.php in your project — this is the single entry point for every HTTP request. It does three things: loads the Composer autoloader, boots the Application by requiring bootstrap/app.php, and hands the incoming request to the HTTP kernel to be turned into a response.
Step 2 — Explore the Service Container
The service container is Laravel's dependency injection engine — a big registry that knows how to build any class you ask for, resolving its constructor dependencies automatically.
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);
});
use Illuminate\Support\Facades\Route;imports theRoutefacade so you can callRoute::get()without typing the full namespace every time.Route::get('/container-demo', function () { ... });registers a route: when a browser sends aGETrequest to/container-demo, the closure below runs.$router = app(\Illuminate\Routing\Router::class);calls Laravel's globalapp()helper, asking the service container to resolve (build) an instance ofRouter. You didn't writenew Router(...)yourself — the container works out whatRouterneeds and constructs it for you. This is dependency injection in action.return get_class($router);returns the fully-qualified class name of whatever object the container gave back, just so you can see it printed in the browser and confirm the container is working.
Step 3 — Bind Your Own Interface
Create an interface and implementation:
mkdir -p app/Contracts
mkdir -p app/Contracts creates a new folder for interfaces (Laravel convention calls these "contracts"). -p means "create parent directories if they don't exist, and don't error if the folder already exists."
// app/Contracts/PaymentGatewayInterface.php
<?php
namespace App\Contracts;
interface PaymentGatewayInterface
{
public function charge(int $amountInCents): bool;
}
namespace App\Contracts;tells PHP's autoloader where this file lives conceptually, matching the folder structure, souse App\Contracts\PaymentGatewayInterface;works anywhere else in the app.interface PaymentGatewayInterface { ... }declares a contract, not an implementation — it says "any class that implements this must provide acharge()method," but doesn't say how.public function charge(int $amountInCents): bool;defines the method signature: it takes an integer number of cents (avoiding floating-point rounding errors with money) and must return true/false for success/failure.
// 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;
}
}
class StripeGateway implements PaymentGatewayInterfaceis the concrete implementation — it promises to fulfil every method the interface requires.- Inside
charge(), in a real app you'd call the Stripe SDK here; we returntrueas a stand-in so we can focus on the architecture rather than a real payment integration.
Step 4 — Register the Binding in a Service Provider
php artisan make:provider PaymentServiceProvider
This artisan command generates a new service provider class in app/Providers/PaymentServiceProvider.php with the boilerplate register()/boot() methods already stubbed out.
// 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);
}
}
class PaymentServiceProvider extends ServiceProvider— service providers are where Laravel apps configure the container; every provider gets aregister()method that runs early in the boot process.$this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);is the key line: it tells the container "whenever any code type-hintsPaymentGatewayInterface, hand it aStripeGatewayinstance instead." This is what makes the interface useful — the rest of your app never needs to know the concrete class name.
Register it in bootstrap/providers.php (Laravel 11+) or config/app.php (older versions):
return [
App\Providers\AppServiceProvider::class,
App\Providers\PaymentServiceProvider::class,
];
This array is the list of providers Laravel boots on every request. Adding your new provider here is what actually activates the binding — a provider class that isn't registered here is never loaded.
Now anywhere you type-hint PaymentGatewayInterface, Laravel injects StripeGateway automatically:
Route::get('/pay', function (PaymentGatewayInterface $gateway) {
return $gateway->charge(1000) ? 'Paid!' : 'Failed';
});
function (PaymentGatewayInterface $gateway)— because this closure is invoked by the router (which goes through the container), Laravel sees the type-hint, looks up your binding, and automatically passes in aStripeGatewayinstance as$gateway. You never callednew StripeGateway()anywhere.$gateway->charge(1000)charges 1000 cents ($10.00) and returnstrue/false, which the ternary turns into a human-readable string.
Why this matters: tomorrow, if you switch payment providers, you change one line in the service provider — every controller, job, and test that depends on PaymentGatewayInterface keeps working unchanged.
Step 5 — Facades vs Dependency Injection
A facade is a static-looking proxy to a container binding — it looks like a static call but actually resolves an object from the container behind the scenes.
// 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);
}
Cache::put('key', 'value', 60);— despite the::syntax suggesting a static method, this line actually asks the container for the bound cache repository and callsput()on it.'key'is the cache key,'value'is what's stored, and60is the time-to-live in seconds.- The DI version does the exact same thing, but the dependency is explicit in the method signature (
\Illuminate\Contracts\Cache\Repository $cache), which makes it trivial to substitute a fake/mock cache in a unit test — you can't easily swap out a facade call inside a test without extra setup.
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
Each command creates two files at once: an Eloquent model (app/Models/Category.php) and a matching migration file in database/migrations. The -m flag is short for --migration, saving you from running a separate make:migration command.
Copy the full files below exactly — artisan generates a migration filename with a timestamp prefix like
2026_07_27_103015_create_categories_table.php; keep whatever timestamp your own generator produced, just replace the body of the file (everything between<?phpand the final};) with what's shown here.
Full file: database/migrations/xxxx_create_categories_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
return new class extends Migration { ... };— Laravel migrations are anonymous classes; this avoids class-name collisions between migration files.Schema::create('categories', function (Blueprint $table) { ... })tells Laravel's schema builder to create a new table calledcategories, using the closure to define its columns.$table->id();adds an auto-incrementingBIGINTprimary key column namedid— the default primary key Eloquent expects.$table->string('name');adds aVARCHARcolumn callednamefor the category's title.$table->timestamps();adds two columns,created_atandupdated_at, which Eloquent fills in automatically whenever a row is created or saved.down(): void { Schema::dropIfExists('categories'); }is the rollback counterpart — runningphp artisan migrate:rollbackcalls this and drops the table, undoingup().
Full file: database/migrations/xxxx_create_posts_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
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->unsignedInteger('price')->default(0);
$table->boolean('is_published')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
$table->foreignId('user_id')->constrained()->cascadeOnDelete();adds auser_idcolumn, then->constrained()automatically links it to theidcolumn on theuserstable (inferred from the column name), enforcing at the database level that every post must belong to a real user.->cascadeOnDelete()means if that user is deleted, all their posts are deleted too, instead of being left orphaned.$table->foreignId('category_id')->constrained()->cascadeOnDelete();does the same thing, linking each post to acategoriesrow.$table->text('body');usesTEXTinstead ofVARCHARbecause post bodies can be much longer than a typical string column allows.$table->unsignedInteger('price')->default(0);stores a price in cents as a plain integer (never adecimal/floatfor money — see the custom cast in Step 5, which converts this back and forth to dollars automatically).unsignedIntegerdisallows negative numbers, since a price should never be negative; it defaults new rows to0.$table->boolean('is_published')->default(false);adds a true/false flag, defaulting every new post to unpublished (draft) until explicitly published.
php artisan migrate
Runs both new migrations, physically creating the categories and posts tables in your database in the order Laravel detects from their timestamped filenames (categories first, since posts depends on it via the foreign key).
Step 2 — Define Relationships, Scopes, Accessors & Casts (Full Model Files)
Rather than piecing the Post model together edit-by-edit, here is the complete, final file with every piece from this session already included — relationships, a local scope, a global scope, an accessor, a mutator, and a custom cast. Replace the entire generated app/Models/Post.php with this:
Full file: app/Models/Post.php
<?php
namespace App\Models;
use App\Casts\Money;
use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'body',
'price',
'is_published',
];
protected $casts = [
'is_published' => 'boolean',
'price' => Money::class,
];
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope);
}
// Relationships
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
// Local scope
public function scopePublished($query)
{
return $query->where('is_published', true);
}
// 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),
);
}
}
Line by line:
use HasFactory;pulls in the trait that lets you callPost::factory()in tests and seeders (built later on Day 3).protected $fillable = [...]is Eloquent's mass-assignment whitelist — only these columns can be set viaPost::create($data)or$post->update($data). Without listing a column here, Eloquent silently ignores it if it's present in the array, protecting against attackers sneaking extra fields (likeis_published => true) into a form submission.protected $casts = ['is_published' => 'boolean', 'price' => Money::class]—'boolean'ensures$post->is_publishedis always a real PHPtrue/false(not the raw0/1MySQL returns), andMoney::classruns the custom cast defined further below every timepriceis read or written.protected static function booted(): voidis a special Eloquent lifecycle method that runs once when the model class is first "booted" by Laravel.static::addGlobalScope(new PublishedScope);attaches the scope (defined in its own file below) so that everyPost::all(),Post::find(), etc. automatically filters to published posts only, unless explicitly bypassed withwithoutGlobalScope().public function user()defines a relationship method (not a regular data property) — calling$post->userwill trigger Eloquent to look this up.return $this->belongsTo(User::class);tells Eloquent "this Post row has auser_idcolumn that points to a row in theuserstable," inferring the foreign key name automatically from the method name and related model.public function category()works identically, but points at theCategorymodel viacategory_id.public function scopePublished($query)— prefixing a method withscopeis a naming convention Eloquent recognizes, turning the method into a reusable, chainable query filter you call asPost::published().return $query->where('is_published', true);adds aWHERE is_published = 1clause and returns the modified query builder so it can be chained further.protected function title(): Attributedefines an accessor using Laravel's modernAttributeclass.get: fn (string $value) => ucfirst($value)runs every time$post->titleis read, capitalizing the first letter — so however the title was stored, it's displayed consistently.protected function body(): Attributedefines a mutator.set: fn (string $value) => strip_tags($value)runs every time$post->body = ...is assigned (including on create/update) — it removes any HTML tags before the value ever reaches the database, protecting against stored XSS.
Full file: app/Models/Category.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name'];
public function posts()
{
return $this->hasMany(Post::class);
}
}
protected $fillable = ['name'];whitelists the only column categories are ever mass-assigned through.public function posts()—hasManyis the inverse ofbelongsTo: it tells Eloquent "many rows in thepoststable can reference this one category," so$category->postsreturns a collection of all matchingPostmodels.
Full file: app/Models/User.php (add the posts() method to Laravel's default generated file — don't replace the whole file, since it already contains password hashing casts and Sanctum setup from earlier steps)
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function posts()
{
return $this->hasMany(Post::class);
}
}
public function posts()is the only new addition here — same idea asCategory::posts(): a user can have many posts, found by matchingposts.user_idtousers.id.- Everything else shown is Laravel's own default scaffolding (password hashing cast, hidden fields for JSON serialization, Sanctum's
HasApiTokenstrait) — included in full here so you can see exactly where the new method fits without guessing.
Full file: 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);
}
}
implements Scoperequires this class to define anapply()method, which Laravel calls automatically on every query built for a model this scope is attached to.$builder->where('is_published', true);adds the filter automatically without you calling anything — useful when a filter should almost never be forgotten (e.g. multi-tenancy, soft-published content).
Full file: app/Casts/Money.php
php artisan make:cast Money
Generates a boilerplate cast class at app/Casts/Money.php implementing the CastsAttributes interface. Replace its contents with:
<?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
}
}
get($model, $key, $value, $attributes)runs whenever the attribute is read from the database —$valueis the raw stored value (cents, e.g.1999), and dividing by 100 converts it back to a human-friendly dollar amount (19.99) for display.set($model, $key, $value, $attributes)runs whenever the attribute is assigned —(int) round($value * 100)takes a dollar figure typed by a developer/user (e.g.19.99) and converts it to an integer number of cents (1999) before it's saved, avoiding floating-point rounding errors that plague money stored as decimals.
Checkpoint: you now have a Post/Category/User model set with real relationships, scopes, accessors, and a custom cast.
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
}
Post::all();runs one query to fetch every post.- Inside the loop,
$post->useris a relationship Eloquent hasn't loaded yet, so it silently runs a newSELECTquery to fetch that specific user — every single iteration. With 100 posts, that's 1 (for posts) + 100 (for users) = 101 queries. This is the classic "N+1" performance bug.
Eager loading (fixed):
$posts = Post::with('user', 'category')->get();
->with('user', 'category') tells Eloquent to pre-fetch both relationships in two extra bulk queries (one WHERE id IN (...) for all needed users, one for all needed categories) before the loop runs. Total: 3 queries no matter how many posts there are, instead of 1 + 2N.
Force-detect N+1 bugs during development, add this line inside the existing boot() method of app/Providers/AppServiceProvider.php:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Model::preventLazyLoading(...)is a global Eloquent setting: when enabled, accessing an un-eager-loaded relationship throws an exception instead of silently querying.! app()->isProduction()means this is turned on everywhere except production — so N+1 bugs crash loudly in local/testing environments (where you'll notice and fix them) but never risk breaking a live site if one slips through.
Testing the Model in Tinker
php artisan tinker opens an interactive PHP shell with your entire Laravel application already booted — models, config, and the database connection are all available immediately, letting you test everything above without writing a controller or route first.
php artisan tinker
Run these one at a time and read the output after each:
// 1. Create a category to attach posts to
$category = \App\Models\Category::create(['name' => 'Tutorials']);
Creates and immediately saves a new row in categories; $category now holds the resulting model, including its auto-generated id.
// 2. Create a user to own the post (skip if you already have one)
$user = \App\Models\User::factory()->create();
Uses the model factory to generate a fake-but-valid user instantly — faster than typing out name/email/password by hand.
// 3. Create a post — note we pass a lowercase title and a dollar price
$post = \App\Models\Post::create([
'user_id' => $user->id,
'category_id' => $category->id,
'title' => 'hello world',
'body' => '<p>Some <b>html</b> content</p>',
'price' => 19.99,
'is_published' => true,
]);
Mass-assigns all six fields at once (only possible because every one of them is listed in Post::$fillable). Watch what comes back:
$post->title;
// => "Hello world" (accessor capitalized it automatically)
$post->body;
// => "Some html content" (mutator stripped the <p>/<b> tags before saving)
$post->price;
// => 19.99 (Money cast converted the stored 1999 cents back to dollars)
// 4. Confirm the raw database value is actually stored in cents
\Illuminate\Support\Facades\DB::table('posts')->find($post->id)->price;
// => 1999
Querying the posts table directly (bypassing Eloquent and its casts entirely) proves the cast is doing real conversion work, not just displaying a formatted string — the database genuinely holds 1999, not 19.99.
// 5. Test the relationships
$post->user->name; // the owning user's name, via belongsTo
$post->category->name; // "Tutorials", via belongsTo
$category->posts; // a Collection containing this post, via hasMany
// 6. Test the local scope
\App\Models\Post::published()->count();
Should return 1 (assuming this is your only post and it was created with is_published => true) — confirming scopePublished() correctly filters.
// 7. Test the global scope is active
$post2 = \App\Models\Post::create([
'user_id' => $user->id,
'category_id' => $category->id,
'title' => 'draft post',
'body' => 'not published yet',
'price' => 0,
'is_published' => false,
]);
\App\Models\Post::all()->count();
// => 1, NOT 2 — the global scope silently excludes the unpublished post
\App\Models\Post::withoutGlobalScope(\App\Models\Scopes\PublishedScope::class)->count();
// => 2 — explicitly bypassing the scope reveals both posts
This confirms PublishedScope is being applied automatically to every query, and that withoutGlobalScope() is the escape hatch when you deliberately need unpublished posts (e.g. an admin dashboard).
// 8. Trigger and observe the N+1 problem, then fix it
\DB::enableQueryLog();
$posts = \App\Models\Post::withoutGlobalScope(\App\Models\Scopes\PublishedScope::class)->get();
foreach ($posts as $p) {
$p->user->name;
}
count(\DB::getQueryLog());
// => 1 (for the posts) + N (one per post's user) queries
DB::enableQueryLog() starts recording every SQL query Laravel runs from this point on; DB::getQueryLog() returns them as an array so you can literally count how many queries fired — a hands-on way to see the N+1 problem happen, not just read about it.
\DB::flushQueryLog();
\DB::enableQueryLog();
$posts = \App\Models\Post::withoutGlobalScope(\App\Models\Scopes\PublishedScope::class)
->with('user', 'category')
->get();
foreach ($posts as $p) {
$p->user->name;
}
count(\DB::getQueryLog());
// => 3 total, regardless of how many posts exist
DB::flushQueryLog() clears the previously recorded queries so this second count isn't polluted by the first experiment. Adding ->with('user', 'category') drops the query count from 1 + N down to a flat 3, proving eager loading works exactly as explained above.
Exit Tinker with exit or Ctrl+D when you're done.
Session 3: Repository & Service Layer Pattern
Step 1 — Why Use a Repository
A repository is a class whose only job is talking to the database on behalf of one model. Putting all Eloquent queries behind a repository interface means your controllers and services depend on an abstraction, not on Eloquent directly — which makes it far easier to unit test (fake the repository) and to swap data sources later without rewriting business logic.
Step 2 — Create the Repository Contract & Implementation
mkdir -p app/Repositories/Contracts
Creates the folder structure to keep repository interfaces (Contracts) separate from their concrete implementations.
// app/Repositories/Contracts/PostRepositoryInterface.php
<?php
namespace App\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use App\Models\Post;
interface PostRepositoryInterface
{
public function all(): Collection;
public function find(int $id): Post;
public function findIncludingDrafts(int $id): Post; // Critical for bypassing Global Scope
public function create(array $data): Post;
public function update(int $id, array $data): Post;
public function delete(int $id): bool;
}
This interface is the "menu" of operations any Post repository must support. Adding modern PHP return types ensures perfect IDE autocompletion and prevents silent bugs.
// app/Repositories/PostRepository.php
<?php
namespace App\Repositories;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
class PostRepository implements PostRepositoryInterface
{
public function all(): Collection
{
// Note: Automatically filters to published only due to PublishedScope (from Session 2)
return Post::with('user', 'category')->latest()->get();
}
public function findIncludingDrafts(int $id): Post
{
// Bypasses the global scope (essential for Admin dashboards or publishing drafts)
return Post::withoutGlobalScope(\App\Models\Scopes\PublishedScope::class)
->with('user', 'category')
->findOrFail($id);
}
public function find(int $id): Post
{
return Post::with('user', 'category')->findOrFail($id);
}
public function create(array $data): Post
{
return Post::create($data);
}
public function update(int $id, array $data): Post
{
$post = $this->find($id); // Reuses find() to trigger 404 automatically if missing
$post->update($data);
return $post->refresh(); // Ensures returned model has the absolute latest DB state
}
public function delete(int $id): bool
{
return (bool) Post::destroy($id);
}
}
Bind it in a provider:
// app/Providers/RepositoryServiceProvider.php
<?php
namespace App\Providers;
use App\Repositories\Contracts\PostRepositoryInterface;
use App\Repositories\PostRepository;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
PostRepositoryInterface::class,
PostRepository::class
);
}
}
Exactly like the PaymentGatewayInterface binding from Session 1 — this line is what makes type-hinting PostRepositoryInterface anywhere in the app automatically hand back a PostRepository instance. (Remember to register this provider in bootstrap/providers.php or config/app.php).
Step 3 — Add a Service Layer for Business Logic
The repository handles data access; the service handles business rules — the "what should happen" logic that isn't just a raw database operation.
// app/Services/PostService.php
<?php
namespace App\Services;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Validation\ValidationException;
class PostService
{
public function __construct(
protected PostRepositoryInterface $posts
) {}
public function createPost(array $data, int $userId): Post
{
$data['user_id'] = $userId;
$data['is_published'] = $data['is_published'] ?? false;
return $this->posts->create($data);
}
public function updatePost(int $postId, array $data): Post
{
return $this->posts->update($postId, $data);
}
public function publish(int $postId): Post
{
// CRITICAL: Use findIncludingDrafts! If we used find(), the Global Scope
// would hide draft posts, making it impossible to publish them (404 error).
$post = $this->posts->findIncludingDrafts($postId);
if (strlen($post->body) < 100) {
throw ValidationException::withMessages([
'body' => ['Post body too short to publish (minimum 100 characters).'],
]);
}
return $this->posts->update($postId, ['is_published' => true]);
}
}
public function __construct(protected PostRepositoryInterface $posts) {}uses PHP 8's constructor property promotion: this single line both declares a$postsproperty and assigns whatever the container injects (aPostRepository, per our binding) into it.- In
createPost(),$data['user_id'] = $userId;stamps the post with whichever user is creating it — a business rule that shouldn't live in the controller or repository. $data['is_published'] = $data['is_published'] ?? false;uses the null-coalescing operator to default new posts to unpublished if the caller didn't specify.publish(int $postId)is a dedicated method. ThrowingValidationExceptionhere is a Laravel best practice: Laravel automatically catches this anywhere it's thrown and converts it into a standard422 Unprocessable EntityJSON response, keeping the controller completely blind to the error details.
Step 4 — Clean Controller
php artisan make:controller PostController
Generates an empty controller class at app/Http/Controllers/PostController.php.
// app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use App\Services\PostService;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function __construct(
protected PostRepositoryInterface $posts,
protected PostService $postService
) {}
public function index(): JsonResponse
{
return response()->json($this->posts->all());
}
public function store(Request $request): JsonResponse
{
// LAB TESTING NOTE: $request->user() will be null in curl without auth.
// For this lab, temporarily hardcode a valid user ID (e.g., $userId = 1;)
// to test the flow, or implement the ForceTestUser middleware from the tutorial notes.
$userId = $request->user() ? $request->user()->id : 1;
$post = $this->postService->createPost(
$request->only(['title', 'body', 'category_id', 'is_published']),
$userId
);
return response()->json($post, 201);
}
public function show(int $id): JsonResponse
{
// Notice we use `int $id`, NOT `Post $post`.
// This prevents a "double query" (Laravel fetching it for route binding, then the repo fetching it again).
return response()->json($this->posts->find($id));
}
public function update(Request $request, int $id): JsonResponse
{
$post = $this->postService->updatePost($id, $request->validated());
return response()->json($post);
}
public function publish(int $id): JsonResponse
{
$post = $this->postService->publish($id);
return response()->json(['message' => 'Published', 'post' => $post]);
}
public function destroy(int $id): JsonResponse
{
$this->posts->delete($id);
return response()->json(null, 204);
}
}
- The constructor injects both the repository (for reads) and the service (for writes with business logic) — Laravel's container resolves both automatically.
index()is a one-liner: it just asks the repository for all posts and returns them as JSON.- In
store(),$request->only([...])extracts only those fields, ignoring anything else the client might have sent (you'll replace this with proper Form Request validation in Session 4). - Important: Methods like
show,update,publish, anddestroynow acceptint $idinstead ofPost $post. This prevents a "double query" where Laravel fetches the model for route model binding, and then the repository fetches it again. The repository'sfind()method handles the 404 check efficiently in a single query.
Notice: the controller has zero Eloquent calls. It only orchestrates — deciding which service/repository method to call and how to shape the HTTP response, nothing more.
Checkpoint: business logic (Service) is separated from data access (Repository), and the Controller is a thin coordinator.
Session 4: Form Requests & Validation
Goal: Move all validation logic out of the controller and into dedicated, reusable classes. This keeps controllers "skinny" and ensures that invalid or malicious data never even reaches your Service Layer.
Step 1 — Generate Form Request Classes
Instead of writing $request->validate([...]) inside the controller, we generate dedicated classes for each specific action.
Run these commands in your terminal:
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest
Why: This creates app/Http/Requests/StorePostRequest.php and UpdatePostRequest.php. Laravel automatically knows to run these classes before the controller method executes if you type-hint them in the controller's method signature.
Step 2 — Define Validation Rules for Creating a Post
Open app/Http/Requests/StorePostRequest.php and replace its contents with this:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// For now, allow anyone to attempt this.
// Later, you can use policies: return $this->user()->can('create', Post::class);
return true;
}
/**
* Get the validation rules that apply to the request.
*/
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'],
];
}
/**
* Get custom error messages for validator errors.
*/
public function messages(): array
{
return [
'category_id.exists' => 'The selected category does not exist in our database.',
];
}
}
Line-by-line breakdown:
authorize(): bool: This is a security gate. If it returnsfalse, Laravel immediately returns a403 Forbiddenresponse. Returningtruemeans "proceed to validation."rules(): array: Defines the constraints.'title' => ['required', 'string', 'max:255']: Must be present, must be text, and cannot exceed the database column'sVARCHAR(255)limit.'body' => ['required', 'string', 'min:20']: Enforces a minimum length to prevent spammy, near-empty posts.'category_id' => ['required', 'exists:categories,id']: This is a powerful database-level check. It queries thecategoriestable to ensure the submittedidactually exists, preventing orphaned foreign keys.'is_published' => ['sometimes', 'boolean']:sometimesis crucial here. It means "only validate this field if the client sends it." If omitted, the field is ignored, allowing our Service Layer's default (false) to take over.
messages(): array: Overrides Laravel's default, sometimes robotic, error messages with human-readable ones for specific rules.
Step 3 — Define Validation Rules for Updating a Post
Open app/Http/Requests/UpdatePostRequest.php and replace its contents:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\Models\Post;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
// Security Best Practice: Ensure the user owns the post they are trying to update
$postId = $this->route('id'); // Gets the {id} parameter from the URL route
$post = Post::find($postId);
// Allow only if the post exists AND belongs to the authenticated user
return $post && $this->user()->id === $post->user_id;
}
public function rules(): array
{
return [
// 'sometimes' allows partial updates (e.g., sending ONLY a new title)
'title' => ['sometimes', 'required', 'string', 'max:255'],
'body' => ['sometimes', 'required', 'string', 'min:20'],
'category_id' => ['sometimes', 'required', 'exists:categories,id'],
];
}
}
Why this is different from StorePostRequest:
- The
authorize()method now actively checks ownership. If User A tries to update User B's post,$this->user()->id === $post->user_idevaluates tofalse, and Laravel blocks the request with a403 Forbiddenbefore any validation even runs. - The
rules()usesometimes. When updating, a client might only want to change thetitle. Withoutsometimes, Laravel would fail the request becausebodyandcategory_idare missing.sometimestells Laravel: "If this field is present in the request, validate it strictly. If it's absent, ignore it."
Step 4 — Custom Validation Logic (Rule Object)
Sometimes, built-in rules aren't enough. For example, we want to block specific banned words in the title.
Generate a custom rule class:
php artisan make:rule NoBannedWords
Open app/Rules/NoBannedWords.php and update it:
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
use Closure;
class NoBannedWords implements ValidationRule
{
protected array $banned = ['spam', 'scam', 'viagra'];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
foreach ($this->banned as $word) {
// Check if the submitted value contains the banned word (case-insensitive)
if (str_contains(strtolower($value), $word)) {
$fail("The {$attribute} contains a banned word: '{$word}'.");
}
}
}
}
How it works:
validate(...)is automatically called by Laravel's validator.$attributeis the name of the field being validated (e.g.,"title").$valueis the actual data the user submitted.$failis a callback function. If you call it, validation fails, and the string you pass becomes the error message.str_contains(strtolower($value), $word)ensures the check is case-insensitive (so "SPAM" is caught just like "spam").
How to use it: Add it to the rules array in StorePostRequest.php:
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255', new NoBannedWords], // <-- Added here
'body' => ['required', 'string', 'min:20'],
'category_id' => ['required', 'exists:categories,id'],
'is_published' => ['sometimes', 'boolean'],
];
}
Step 5 — Request Sanitization (Defense in Depth)
Validation checks if data is valid, but sanitization cleans data to make it safe. We can strip HTML tags from the title before validation even runs.
Add this method to both StorePostRequest.php and UpdatePostRequest.php:
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
// Overwrite the incoming 'title' with a sanitized version
'title' => strip_tags($this->input('title')),
]);
}
Why this matters: prepareForValidation() runs before rules(). By using $this->merge(), we permanently alter the request data for the rest of the application lifecycle. Even if a malicious user sends <script>alert(1)</script>My Title, it becomes My Title before it ever hits your database or validation rules. This is "defense in depth" alongside the body mutator we built in Session 2.
Step 6 — Use It in the Controller
Now, wire it all together. Open app/Http/Controllers/PostController.php.
Find the store method and change the type-hint from Request to StorePostRequest:
use App\Http\Requests\StorePostRequest; // Ensure this is imported at the top
public function store(StorePostRequest $request): JsonResponse
{
// Lab testing fallback for unauthenticated curl requests
$userId = $request->user() ? $request->user()->id : 1;
// $request->validated() ONLY returns fields that passed the rules() array
$post = $this->postService->createPost(
$request->validated(),
$userId
);
return response()->json($post, 201);
}
Do the same for the update method:
use App\Http\Requests\UpdatePostRequest; // Ensure this is imported
public function update(UpdatePostRequest $request, int $id): JsonResponse
{
$post = $this->postService->updatePost($id, $request->validated());
return response()->json($post);
}
Why this is magical:
- You didn't write a single
if ($request->has(...))or$request->validate(...). - By simply type-hinting
StorePostRequest, Laravel's service container intercepts the request, instantiates the class, runsauthorize(), then runsrules(). - If validation fails, Laravel automatically halts execution and returns a
422 Unprocessable EntityJSON response with the exact error messages. The controller code never even runs. - If validation passes,
$request->validated()acts as a secure whitelist. It returns an array containing only the fields that were defined inrules(). If a hacker tries to sneak'is_admin' => trueinto the JSON payload, it is silently discarded because it wasn't in the rules array.
Checkpoint: Verify Validation Works
Test that your new guards are active using curl. Pro-tip: Always include the Accept: application/json header when testing APIs with curl. This forces Laravel to return JSON error responses instead of redirecting you to an HTML login page if something goes wrong.
1. Test missing required field (Should return 422):
curl -X POST http://127.0.0.1:8000/api/lab/posts \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"title":"Missing Body"}'
Expected Output: A 422 response showing errors for body and category_id.
2. Test banned word rule (Should return 422):
curl -X POST http://127.0.0.1:8000/api/lab/posts \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"title":"This is a spam post","body":"A valid body that is long enough to pass the minimum length check.","category_id":1}'
Expected Output: A 422 response with the error: {"title": ["The title contains a banned word: 'spam'."]}
3. Test successful creation (Should return 201):
curl -X POST http://127.0.0.1:8000/api/lab/posts \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"title":"Clean Valid Title","body":"This is a perfectly valid body that exceeds twenty characters.","category_id":1}'
Expected Output: 201 Created with the new post JSON.
Checkpoint: Invalid input never reaches your service layer. Your controller is clean, and your validation is robust, reusable, and secure.
Day 1 Hands-On Lab: Refactor a CRUD App
Goal: take a "fat controller" CRUD and refactor it into the repository + service pattern from Session 3, with validation from Session 4 — ending with a controller that has zero direct Eloquent calls.
Step 0 — Register Routes for the Lab
⚠️ CRITICAL: We use routes/api.php here to bypass Laravel's CSRF protection, ensuring our curl commands work without "Page Expired" errors.
// routes/api.php
<?php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/lab/posts', [PostController::class, 'index']);
Route::post('/lab/posts', [PostController::class, 'store']);
Route::get('/lab/posts/{id}', [PostController::class, 'show']);
Route::put('/lab/posts/{id}', [PostController::class, 'update']);
Route::patch('/lab/posts/{id}/publish', [PostController::class, 'publish']);
Route::delete('/lab/posts/{id}', [PostController::class, 'destroy']);
Laravel 11 Only: You must enable API routing. Open bootstrap/app.php and ensure the api route is loaded:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // 👈 ADD THIS LINE
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
// ...
Step 1 — Start From the "Before" Version
This is deliberately what not to write long-term — a controller doing every job itself. Create it exactly like this first, confirm it works, then you'll refactor it piece by piece.
// app/Http/Controllers/PostController.php — BEFORE
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function index(): JsonResponse
{
return Post::with('user', 'category')->latest()->get();
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string|min:20',
'category_id' => 'required|exists:categories,id',
]);
// Lab testing fallback for unauthenticated curl requests
$userId = $request->user() ? $request->user()->id : 1;
$validated['user_id'] = $userId;
$validated['is_published'] = false;
$post = Post::create($validated);
return response()->json($post, 201);
}
public function show(int $id): JsonResponse
{
return Post::with('user', 'category')->findOrFail($id);
}
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'body' => 'sometimes|required|string|min:20',
'category_id' => 'sometimes|required|exists:categories,id',
]);
$post = Post::findOrFail($id);
$post->update($validated);
return response()->json($post);
}
public function publish(int $id): JsonResponse
{
// business rule buried directly in the controller
$post = Post::findOrFail($id);
if (strlen($post->body) < 100) {
return response()->json(['message' => 'Post body too short to publish'], 422);
}
$post->update(['is_published' => true]);
return response()->json($post);
}
public function destroy(int $id): JsonResponse
{
Post::destroy($id);
return response()->json(null, 204);
}
}
Confirm it works before touching anything else — this matters because the whole point of the refactor is that behavior doesn't change, so you need a known-good baseline to compare against:
php artisan route:list --path=lab
curl -X POST http://127.0.0.1:8000/api/lab/posts \
-H "Content-Type: application/json" \
-d '{"title":"hello","body":"this is a long enough body to pass validation checks","category_id":1}'
Note the returned id, then try:
curl http://127.0.0.1:8000/api/lab/posts/1
curl -X PATCH http://127.0.0.1:8000/api/lab/posts/1/publish \
-H "Content-Type: application/json"
Confirm the publish attempt fails with the "too short" message (since the seed body is under 100 characters), then try it again with a longer body until it succeeds. Keep these exact curl commands handy — you'll re-run them after every refactor step.
Step 2 — Extract the Repository
Pull every direct Post::... Eloquent call out of the controller and into a repository, exactly as built in Session 3. Ensure you include the findIncludingDrafts method and strict return types. Bind it in RepositoryServiceProvider and register the provider.
Don't touch the controller yet. At this point the repository exists but nothing calls it — the "before" controller still works exactly as it did in Step 1, because you haven't wired anything into it. This is intentional: adding new code before removing old code means you can build and check each piece in isolation.
Step 3 — Extract the Service Layer
Now move the "only publish if body length > 100" rule (currently sitting inside the controller's publish() method) into a dedicated service, along with the create logic, exactly as defined in Session 3, Step 3.
Notice the "body too short" rule moved verbatim — same 100-character check — but now lives in one place. Throwing ValidationException::withMessages([...]) replaces the controller's manual return response()->json(..., 422) — Laravel automatically catches this exception anywhere it's thrown and converts it into the same 422 JSON error shape, so the behavior is identical, but now it's a real validation error rather than a hand-rolled response.
Step 4 — Rewrite the Controller to Use Them
This is the actual refactor — replace the entire "before" controller with the clean version from Session 3, Step 4. Every method is now 1–3 lines and contains zero Post:: calls — it only asks the repository or service to do something and shapes the HTTP response.
Step 5 — Replace Inline Validation with Form Requests
Ensure StorePostRequest and UpdatePostRequest are created and populated with the rules from Session 4. Notice the controller now type-hints these requests, and uses $request->validated() to guarantee no malicious extra fields reach the service layer.
Step 6 — Re-Test Every Endpoint and Confirm Nothing Changed
php artisan route:list --path=lab
--path=lab filters the output down to only the lab's own routes, so you can quickly confirm all six are still registered pointing at PostController and none were accidentally dropped or renamed during the rewrite.
Re-run the exact same curl commands from Step 1 (using the /api/ prefix), in the same order, and confirm you get the same results:
curl -X POST http://127.0.0.1:8000/api/lab/posts \
-H "Content-Type: application/json" \
-d '{"title":"hello","body":"this is a long enough body to pass validation checks","category_id":1}'
Should still return 201 with the created post.
curl http://127.0.0.1:8000/api/lab/posts/2
Should still return the post (now routed through PostRepository::find() instead of a raw Post::with(...) call).
curl -X PATCH http://127.0.0.1:8000/api/lab/posts/2/publish \
-H "Content-Type: application/json"
Should still return the 422 "body too short" error for a short post, and succeed for a post whose body is 100+ characters — proving the business rule survived the move into PostService::publish() unchanged.
curl -X DELETE http://127.0.0.1:8000/api/lab/posts/2
Should still return 204 No Content.
If every response matches what you got in Step 1, the refactor is behavior-preserving — which is the actual goal of a refactor: the external behavior is identical, while the internal structure is now testable, reusable, and far easier to extend on Day 2 and Day 3.
Deliverable: a PostController with only 1–3 lines per method, no direct Eloquent calls, full validation via Form Requests, and a passing set of curl checks proving behavior didn't regress.