Laravel Development
Modern Laravel development patterns, best practices, and workflows.
Runner Selection
# With Laravel Sail (Docker)
sail artisan <command>
sail composer <command>
sail npm <command>
# Without Sail (local PHP)
php artisan <command>
composer <command>
npm <command>
Eloquent Relationships & Loading
Eager Loading (Prevent N+1)
// BAD: N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Query per post
}
// GOOD: Eager loading
$posts = Post::with(['author', 'tags'])->get();
// Constrained eager loading
User::with(['posts' => fn($q) => $q->latest()->where('published', true)])->find($id);
// With counts and aggregates
Post::withCount('comments')->withSum('orders', 'total')->get();
Relationships
// Define clear relationships
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// Pivot operations
$post->tags()->sync([1, 2, 3]); // Replace all
$post->tags()->syncWithoutDetaching([4]); // Add without removing
$post->tags()->attach($tagId); // Add one
$post->tags()->detach($tagId); // Remove one
Migrations & Factories
Migrations
// Create migration
// sail artisan make:migration create_posts_table
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('content');
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
Factories
class PostFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'title' => fake()->sentence(),
'slug' => fake()->unique()->slug(),
'content' => fake()->paragraphs(3, true),
'status' => 'draft',
];
}
public function published(): static
{
return $this->state(fn() => [
'status' => 'published',
'published_at' => now(),
]);
}
}
// Usage
Post::factory()->count(10)->published()->create();
Post::factory()->for(User::factory()->admin())->create();
Form Requests & Validation
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'unique:posts'],
'content' => ['required', 'string'],
'status' => ['required', Rule::in(['draft', 'published'])],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
];
}
public function messages(): array
{
return [
'title.required' => 'Post title is required.',
'slug.unique' => 'This slug is already taken.',
];
}
}
// Controller usage
public function store(StorePostRequest $request): JsonResponse
{
$post = Post::create($request->validated());
return response()->json($post, 201);
}
API Resources
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => Str::limit($this->content, 150),
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments_count' => $this->whenCounted('comments'),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// Paginated response
return PostResource::collection(
Post::with(['author', 'tags'])
->withCount('comments')
->latest()
->paginate(20)
);
TDD with Pest
RED-GREEN-REFACTOR Cycle
// 1. RED: Write failing test first
it('creates a post with valid data', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'My Post',
'slug' => 'my-post',
'content' => 'Post content here',
'status' => 'draft',
]);
$response->assertCreated()
->assertJsonPath('data.title', 'My Post');
$this->assertDatabaseHas('posts', [
'title' => 'My Post',
'user_id' => $user->id,
]);
});
it('rejects empty title', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => '',
'slug' => 'test',
'content' => 'Content',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('title');
});
// 2. GREEN: Write minimal code to pass
// 3. REFACTOR: Clean up while keeping tests green
Run Tests
# All tests (parallel)
sail artisan test --parallel
# Specific test file
sail artisan test tests/Feature/PostTest.php
# With coverage
sail artisan test --coverage --min=80
Queues & Horizon
Job Definition
class ProcessUpload implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 300;
public function __construct(
public Upload $upload
) {}
public function handle(): void
{
// Process the upload
$this->upload->process();
}
public function failed(Throwable $exception): void
{
Log::error('Upload processing failed', [
'upload_id' => $this->upload->id,
'error' => $exception->getMessage(),
]);
}
}
// Dispatch
ProcessUpload::dispatch($upload);
ProcessUpload::dispatch($upload)->onQueue('uploads');
ProcessUpload::dispatch($upload)->delay(now()->addMinutes(5));
Horizon Configuration
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
],
Caching
// Simple caching
$posts = Cache::remember('posts.featured', 3600, function () {
return Post::featured()->with('author')->get();
});
// Cache tags (Redis required)
Cache::tags(['posts', 'users'])->put('user.1.posts', $posts, 3600);
Cache::tags('posts')->flush();
// Model caching pattern
class Post extends Model
{
protected static function booted(): void
{
static::saved(fn() => Cache::tags('posts')->flush());
static::deleted(fn() => Cache::tags('posts')->flush());
}
}
Routes Best Practices
// api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
Route::post('posts/{post}/publish', [PostController::class, 'publish']);
Route::prefix('admin')->middleware('can:admin')->group(function () {
Route::apiResource('users', Admin\UserController::class);
});
});
// Rate limiting
Route::middleware(['throttle:api'])->group(function () {
Route::get('/search', SearchController::class);
});
Policies & Authorization
class PostPolicy
{
public function view(?User $user, Post $post): bool
{
return $post->status === 'published' || $user?->id === $post->user_id;
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->isAdmin();
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->isAdmin();
}
}
// Controller usage
public function update(UpdatePostRequest $request, Post $post)
{
$this->authorize('update', $post);
// ...
}
Exception Handling
// app/Exceptions/Handler.php
public function register(): void
{
$this->renderable(function (ModelNotFoundException $e, Request $request) {
if ($request->wantsJson()) {
return response()->json(['message' => 'Resource not found'], 404);
}
});
$this->renderable(function (AuthorizationException $e, Request $request) {
if ($request->wantsJson()) {
return response()->json(['message' => 'Forbidden'], 403);
}
});
}
Quality Checks
# Laravel Pint (code style)
./vendor/bin/pint
# PHPStan (static analysis)
./vendor/bin/phpstan analyse
# PHP Insights (code quality)
./vendor/bin/phpinsights
# All checks
./vendor/bin/pint && ./vendor/bin/phpstan analyse && sail artisan test
Blade Components
// Component class
class Alert extends Component
{
public function __construct(
public string $type = 'info',
public ?string $message = null
) {}
public function render(): View
{
return view('components.alert');
}
}
// Blade template
<x-alert type="success" :message="$message" />
// Anonymous component (resources/views/components/button.blade.php)
@props(['type' => 'button', 'variant' => 'primary'])
<button type="{{ $type }}" {{ $attributes->merge(['class' => "btn btn-{$variant}"]) }}>
{{ $slot }}
</button>
Performance Tips
- Use eager loading - Always
with()relationships you'll access - Select specific columns -
->select(['id', 'name'])when possible - Use chunking for large datasets -
->chunk(1000, fn($batch) => ...) - Cache expensive queries - Use
Cache::remember() - Index database columns - Add indexes for frequently queried columns
- Use queues - Offload heavy processing to background jobs
- Enable OPcache - In production for PHP performance