All articles
LaravelPHPArchitectureBest PracticesClean Code

Laravel Patterns I Use on Every Project (And Why)

PKParth Kanasagra
9 min read

Some of these will be obvious. Some will be things you've heard before but never actually done. A few might be controversial. I don't care — these are the patterns I apply on every new Laravel project because I've seen what happens when they're absent.

Thin Controllers

A controller method should do roughly three things: validate input, call something to do the work, return a response. If I'm writing business logic in a controller, something's wrong.

// I write controllers that look like this
class PostController extends Controller
{
    public function store(StorePostRequest $request, CreatePost $action): RedirectResponse
    {
        $post = $action->execute($request->validated(), $request->user());

        return redirect()->route('posts.show', $post)
                         ->with('success', 'Post published.');
    }
}

The CreatePost action class handles the actual work. It can be tested independently of HTTP concerns. When the requirements change, I know exactly where to look.

Form Requests — Always

Validation and authorization in one place, out of the controller:

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'],
            'content' => ['required', 'string', 'min:100'],
            'tags'    => ['array', 'max:5'],
            'tags.*'  => ['string', 'max:50'],
        ];
    }
}

The authorization in authorize() runs before validation. Unauthorized requests never make it to the rules — they get a 403 immediately.

API Resources: Every Single Time

Returning raw Eloquent models means your database schema is your public API. When you rename a column, you break integrations. When you add a sensitive field, you might accidentally expose it. API Resources create a deliberate layer between your DB and your responses.

Prevent Lazy Loading in Development

I already wrote a whole post about N+1 queries, but this deserves repeating: add this to your AppServiceProvider right now:

Model::preventLazyLoading(! app()->isProduction());

It costs nothing and catches an entire category of performance bugs before they reach production.

Typed Everything

PHP 8.4 has excellent type system support. I use it:

class NotificationService
{
    public function sendWelcome(User $user): void
    {
        $user->notify(new WelcomeNotification);
    }

    /** @return Collection<int, Notification> */
    public function recent(User $user, int $limit = 10): Collection
    {
        return $user->notifications()->limit($limit)->get();
    }
}

Static analysis tools (PHPStan with Larastan) can catch real bugs before you run the code. I run it in CI on every pull request.

Test Every Route

When people ask me "how do I write better code?", the answer is almost always: write tests first. Not TDD as a religion, just the discipline of having a test for every endpoint.

it('allows authors to delete their own posts', function () {
    $author = User::factory()->create();
    $post   = Post::factory()->for($author)->create();

    $this->actingAs($author)
         ->deleteJson(route('posts.destroy', $post))
         ->assertNoContent();

    assertModelMissing($post);
});

it('prevents non-authors from deleting posts', function () {
    $post = Post::factory()->create();

    $this->actingAs(User::factory()->create())
         ->deleteJson(route('posts.destroy', $post))
         ->assertForbidden();
});

Use Enums for Status Fields

String status fields like is_active and status = 'pending' are fine until someone typos "pendng" somewhere in the codebase. Backed enums eliminate that:

enum PostStatus: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';

    public function label(): string
    {
        return match($this) {
            self::Draft     => 'Draft',
            self::Published => 'Published',
            self::Archived  => 'Archived',
        };
    }
}

// In the model
protected function casts(): array
{
    return ['status' => PostStatus::class];
}

// In a query
Post::where('status', PostStatus::Published)->get();

Queue Everything That Can Wait

If a task doesn't need to complete before the user sees a response, queue it. Sending a welcome email, generating a PDF, indexing a document in Elasticsearch, notifying Slack — all of these can be queued. The response time your users experience is directly affected by this discipline.

Related Articles

PK

Parth Kanasagra

Certified Senior Laravel & PHP Developer · 9+ years experience · Available for hire