Laravel Guide
Applies to: Laravel 11+, PHP 8.2+, Full-Stack Web Applications, APIs, SaaS
Core Principles
- •Convention Over Configuration: Follow Laravel's conventions for file placement, naming, and structure
- •Service Layer: Keep controllers thin, extract business logic into service classes
- •Form Requests: Validate all input through dedicated Form Request classes
- •Eloquent Relationships: Use eager loading to prevent N+1 queries
- •Queue Everything Slow: Offload email, notifications, and heavy processing to queues
Guardrails
Version & Dependencies
- •Use Laravel 11+ with PHP 8.2+
- •Run
composer install --no-devfor production - •Use Laravel Pint for code formatting (ships with Laravel)
- •Use PHPStan or Larastan for static analysis
- •Lock dependency versions in
composer.lock
Code Style
- •Always use
declare(strict_types=1);at the top of every PHP file - •Use constructor property promotion for dependency injection
- •Use typed properties and return types on all methods
- •Follow PSR-12 coding standard (enforced by Pint)
- •Use
readonlyproperties where values should not change after construction
Security
- •Never use
DB::raw()without parameter binding - •Always validate input through Form Requests, never in controllers
- •Use Sanctum for API authentication, not plain tokens
- •Never commit
.envfiles; use.env.exampleas template - •Use
$fillable(whitelist) on models, never$guarded = [] - •Escape Blade output with
{{ }}(not{!! !!}unless explicitly safe) - •Use CSRF protection on all web routes (enabled by default)
Performance
- •Always eager load relationships:
->with('relation')or->load('relation') - •Use pagination for list endpoints:
->paginate(15)or->cursorPaginate(15) - •Cache expensive queries with
Cache::remember() - •Use database indexes on columns used in
WHERE,ORDER BY,JOIN - •Queue emails, notifications, and any operation >200ms
Project Structure
code
myapp/ ├── app/ │ ├── Console/Commands/ # Artisan commands │ ├── Enums/ # PHP enums (roles, statuses) │ ├── Events/ # Domain events │ ├── Http/ │ │ ├── Controllers/ # Thin controllers (delegate to services) │ │ ├── Middleware/ # HTTP middleware │ │ └── Requests/ # Form Request validation │ ├── Jobs/ # Queued jobs │ ├── Listeners/ # Event listeners │ ├── Models/ # Eloquent models │ ├── Policies/ # Authorization policies │ ├── Providers/ # Service providers │ └── Services/ # Business logic layer ├── config/ # Configuration files ├── database/ │ ├── factories/ # Model factories for testing │ ├── migrations/ # Always include down() for rollback │ └── seeders/ # Database seeders ├── resources/views/ # Blade templates ├── routes/ │ ├── api.php # API routes (versioned: /api/v1/) │ └── web.php # Web routes ├── tests/ │ ├── Feature/ # Integration/HTTP tests │ └── Unit/ # Isolated unit tests ├── .env.example ├── composer.json └── phpunit.xml
Eloquent Models
Defining Models
php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'slug',
'body',
'status',
'user_id',
];
protected $casts = [
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
// -- Relationships --
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// -- Scopes --
public function scopePublished($query)
{
return $query->where('status', 'published')
->whereNotNull('published_at');
}
// -- Accessors (Laravel 11 attribute casting) --
public function getExcerptAttribute(): string
{
return str()->limit($this->body, 150);
}
}
Key Eloquent Rules
- •Always declare
$fillable(whitelist) for mass assignment protection - •Use
$castsfor type casting (datetime, boolean, enum, array, json) - •Type-hint relationship return types (
HasMany,BelongsTo, etc.) - •Use SoftDeletes when data should be recoverable
- •Name scopes descriptively:
scopeActive,scopePublished,scopeByRole
Routing
Web Routes
php
<?php
// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('welcome'));
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class);
});
API Routes
php
<?php
// routes/api.php
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
});
});
Routing Rules
- •Group routes with shared middleware using
Route::middleware()->group() - •Use
apiResourcefor API controllers (excludescreate/editviews) - •Version API routes with prefix:
/api/v1/... - •Use route model binding for automatic model resolution
- •Name routes explicitly when needed:
->name('posts.publish')
Controllers
Controller Pattern (Thin Controller)
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use App\Services\PostService;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function __construct(
private readonly PostService $postService,
) {}
public function index(): PostResource
{
return PostResource::collection(
Post::with('author')->published()->paginate(15)
);
}
public function store(StorePostRequest $request): JsonResponse
{
$post = $this->postService->create($request->validated());
return PostResource::make($post)->response()->setStatusCode(201);
}
public function show(Post $post): PostResource
{
return PostResource::make($post->load('author', 'comments'));
}
public function destroy(Post $post): JsonResponse
{
$this->authorize('delete', $post);
$this->postService->delete($post);
return response()->json(null, 204);
}
}
Controller Rules
- •Delegate business logic to Service classes (thin controllers)
- •Use constructor injection with
readonlyfor dependencies - •Use Form Requests for validation, API Resources for responses
- •Use
$this->authorize()or Policy middleware for authorization - •One resource per controller; avoid custom methods beyond CRUD
Blade Templates
Component Pattern
blade
{{-- resources/views/components/alert.blade.php --}}
@props(['type' => 'info', 'dismissible' => false])
<div {{ $attributes->merge(['class' => "alert alert-{$type}"]) }}>
{{ $slot }}
@if($dismissible)
<button type="button" class="close">×</button>
@endif
</div>
Blade Rules
- •Use
{{ }}for escaped output (default);{!! !!}only for trusted HTML - •Use
@propsin components for typed attributes with defaults - •Use
@stack/@pushfor page-specific CSS/JS - •Use
@includefor partials,<x-component>for reusable components - •Use
@vite()for asset bundling (replaces Laravel Mix) - •Never put business logic in Blade templates
Form Request Validation
php
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('posts')],
'body' => ['required', 'string', 'min:10'],
'status' => ['required', Rule::in(['draft', 'published'])],
];
}
}
Middleware
php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string $role): Response
{
if ($request->user()?->role !== $role) {
abort(403, "Role '{$role}' is required.");
}
return $next($request);
}
}
Register in bootstrap/app.php (Laravel 11+):
php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \App\Http\Middleware\EnsureUserHasRole::class,
]);
})
Artisan Commands
bash
# Scaffolding php artisan make:model Post -mfcs # Model + migration + factory + controller + seeder php artisan make:controller Api/PostController --api php artisan make:request StorePostRequest php artisan make:resource PostResource php artisan make:event PostPublished php artisan make:job ProcessReport php artisan make:policy PostPolicy --model=Post # Database php artisan migrate # Run pending migrations php artisan migrate:fresh --seed # Reset DB and seed (dev only) # Cache & Optimization php artisan optimize # Cache config, routes, views php artisan optimize:clear # Clear all caches (dev) # Queue & Testing php artisan queue:work # Start queue worker php artisan test # Run PHPUnit php artisan test --filter=PostTest # Run specific test php artisan test --coverage # With coverage report
Testing
Feature Test Example
php
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/posts', [
'title' => 'My Post',
'slug' => 'my-post',
'body' => 'Post content here.',
'status' => 'draft',
]);
$response->assertCreated()
->assertJsonPath('data.title', 'My Post');
$this->assertDatabaseHas('posts', ['slug' => 'my-post']);
}
public function test_guest_cannot_create_post(): void
{
$this->postJson('/api/v1/posts', ['title' => 'My Post'])
->assertUnauthorized();
}
}
Testing Rules
- •Use
RefreshDatabasetrait for database tests - •Use model factories for test data (never insert raw SQL)
- •Test happy paths, validation failures, and authorization
- •Name tests descriptively:
test_guest_cannot_delete_post - •Use
actingAs()for authenticated requests - •Assert both HTTP status and database state
- •See references/patterns.md for factory patterns and advanced testing
Migrations
php
<?php
declare(strict_types=1);
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->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
- •Always include
down()for rollback - •Use
foreignId()->constrained()for foreign keys with cascading - •Add indexes on columns used in
WHERE,ORDER BY - •Never modify a deployed migration; create a new one instead
Advanced Topics
For detailed patterns and examples, see:
- •references/patterns.md -- Eloquent patterns, jobs/queues, events, Livewire, API resources, deployment