DEV Community

Cover image for How I Structure Large Laravel Projects (My Personal Architecture Blueprint)
Michael Laweh
Michael Laweh

Posted on • Originally published at klytron.com

How I Structure Large Laravel Projects (My Personal Architecture Blueprint)

Every Laravel tutorial starts the same way: create a controller, write some Eloquent in it, return a view. It works for a demo. It collapses under the weight of a real application.

After 16+ years of shipping production systems — from SMS platforms processing hundreds of thousands of messages to enterprise business management systems — I've converged on a specific architectural blueprint that I apply to every serious Laravel project. It isn't theoretical. It's the exact structure running klytron.com right now, and it's the same pattern I deploy on client engagements.

This is the blueprint.

The Problem With "Laravel Default"

Out of the box, Laravel gives you:

app/
├── Http/Controllers/
├── Models/
└── Providers/
Enter fullscreen mode Exit fullscreen mode

This is fine for a prototype. For anything beyond 10 controllers, you start hitting the wall:

  • Fat controllers — business logic, validation, data transformation, and view preparation all crammed into one method.
  • Untestable code — when your controller directly calls File::get() or Cache::remember(), you can't unit test without hitting the filesystem.
  • Rigid coupling — swapping a data source (database → flat files, for example) requires rewriting controllers instead of swapping an implementation.

The fix isn't a framework problem. It's an architecture problem.

My Production Directory Structure

Here's the structure I use on every serious Laravel project:

app/
├── Http/
│   └── Controllers/           # Thin — delegates to services immediately
│       ├── BlogController.php
│       ├── HomeController.php
│       ├── PageController.php
│       ├── ProjectController.php
│       └── ImageController.php
├── Services/                  # Business logic lives here
│   ├── ContentService.php
│   ├── ContentServiceInterface.php
│   ├── SeoService.php
│   └── PostTransformer.php
├── ViewComposers/             # View-specific data preparation
│   └── ThemeComposer.php
└── Models/                    # Eloquent models (when needed)

Enter fullscreen mode Exit fullscreen mode

The key principle: controllers are routers, not thinkers. A controller receives a request, calls a service, and returns a response. That's it.

Principle 1: Service Layer With Interfaces

This is the single most important architectural decision I make. Every piece of business logic gets extracted into a service class, and every service implements an interface.

<?php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Collection;

interface ContentServiceInterface
{
    public function get(string $path): array;
    public function all(string $directory): Collection;
    public function clearCache(?string $directory = null): void;
}
Enter fullscreen mode Exit fullscreen mode

The implementation:

<?php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use League\CommonMark\CommonMarkConverter;
use Spatie\YamlFrontMatter\YamlFrontMatter;

class ContentService implements ContentServiceInterface
{
    public function __construct(
        private readonly string $contentPath,
        private readonly CommonMarkConverter $converter,
        private readonly int $cacheTtl = 3600,
    ) {}

    public function get(string $path): array
    {
        $cacheKey = 'content.' . str_replace('/', '.', $path);

        return Cache::lock("{$cacheKey}.lock", 5)
            ->block(2, fn () => Cache::remember(
                $cacheKey,
                $this->cacheTtl,
                fn () => $this->parse($path)
            ));
    }

    public function all(string $directory): Collection
    {
        return Cache::remember(
            'content.dir.' . str_replace('/', '.', $directory),
            $this->cacheTtl,
            fn () => collect(glob("{$this->contentPath}/{$directory}/*.md"))
                ->map(fn (string $file) => $this->parse(
                    "{$directory}/" . basename($file, '.md')
                ))
                ->filter(fn (array $item) => ($item['status'] ?? '') === 'published')
                ->sortByDesc('published_at')
                ->values()
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

Why the interface matters

In tests, I can swap ContentService for a fake:

$this->app->bind(ContentServiceInterface::class, FakeContentService::class);
Enter fullscreen mode Exit fullscreen mode

If I ever migrate from flat files to a database, I write a new DatabaseContentService implementing the same interface. Zero controller changes. Zero view changes. The swap happens in one line in AppServiceProvider.

This isn't theoretical — I literally migrated this site from a database-backed Botble CMS to a flat-file Markdown CMS, and the interface boundary is what made that migration clean.

Principle 2: Thin Controllers, Always

Here's what a controller method looks like in my projects:

public function show(string $slug, ContentServiceInterface $contentService): View
{
    $post = $contentService->get("blog/{$slug}");

    $relatedPosts = $contentService->all('blog')
        ->where('category', $post['category'])
        ->where('slug', '!=', $slug)
        ->take(3);

    return view('blog.show', compact('post', 'relatedPosts'));
}

Enter fullscreen mode Exit fullscreen mode

Five lines. No business logic. The controller doesn't know whether content comes from a database, a filesystem, or an API. It asks the service, gets data, returns a view.

The rule I enforce: if a controller method exceeds 15 lines, something needs to be extracted into a service or a transformer.

Principle 3: View Composers for Cross-Cutting Data

Every page on my site needs theme configuration — colours, fonts, menu items, social links, footer content. Passing this through every controller method is tedious and violates DRY.

View Composers solve this cleanly:

<?php

namespace App\ViewComposers;

use App\Services\ContentServiceInterface;
use Illuminate\View\View;

class ThemeComposer
{
    public function __construct(
        private readonly ContentServiceInterface $contentService,
    ) {}

    public function compose(View $view): void
    {
        $theme = $this->contentService->get('settings/theme');
        $view->with('theme', $theme);
    }
}

Enter fullscreen mode Exit fullscreen mode

Registered in a service provider:

View::composer('*', ThemeComposer::class);

Now every single view in the application has access to $theme without any controller intervention. Menu items, social links, primary colours — all injected automatically from a single Markdown configuration file.

Principle 4: Transformer Classes for Data Shaping

When the same data needs to be presented differently depending on context (list view vs. detail view vs. feed entry), I use transformer classes:

<?php

namespace App\Services;

class PostTransformer
{
    public function transform(array $post): array
    {
        return array_merge($post, [
            'formatted_date' => Carbon::parse($post['published_at'])->format('M d, Y'),
            'reading_time'   => $this->calculateReadTime($post['content']),
            'excerpt'        => Str::limit(strip_tags($post['content']), 200),
        ]);
    }

    public function transformListItem(array $post): array
    {
        return [
            'title'      => $post['title'],
            'slug'       => $post['slug'],
            'excerpt'    => $post['excerpt'] ?? '',
            'category'   => $post['category'] ?? '',
            'hero_image' => $post['hero_image'] ?? '',
            'date'       => Carbon::parse($post['published_at'])->format('M d, Y'),
        ];
    }
}

Enter fullscreen mode Exit fullscreen mode

This keeps views dumb (they just render what they're given) and controllers thin (they just call transformers). The transformation logic is isolated, testable, and reusable.

Principle 5: Configuration-Driven Architecture

Hard-coded values are technical debt. I centralise site-wide configuration in dedicated config files:

// config/site.php
return [
    'url'     => env('SITE_URL', 'https://klytron.com'),
    'author'  => [
        'name'  => 'Michael K. Laweh',
        'email' => 'hi@klytron.com',
        'title' => 'Senior IT Consultant & Digital Solutions Architect',
    ],
    'socials' => [
        ['icon' => 'ri-github-fill', 'url' => 'https://github.com/klytron'],
        ['icon' => 'ri-linkedin-fill', 'url' => 'https://linkedin.com/in/klytron'],
    ],
];
Enter fullscreen mode Exit fullscreen mode

Accessed via config('site.author.name') — never a string literal scattered across controllers.

Principle 6: SEO as a First-Class Service

SEO is not an afterthought; it's a service with its own class:

class SeoService
{
    public function getMeta(): array { /* ... */ }
    public function getBlogPostMeta(array $post): array { /* ... */ }
    public function getSchemaOrg(string $type, array $data): string { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Every page type has dedicated meta generation. Schema.org JSON-LD is injected per-page. Open Graph tags are dynamic. The sitemap, RSS/Atom/JSON feeds, and OpenSearch descriptor are all generated from the same ContentService data — ensuring they never go stale.

Principle 7: Blade Component System

Raw HTML in Blade templates leads to inconsistency. I use a full component system:

{{-- Instead of raw HTML buttons everywhere: --}}
<a class="btn btn-primary" href="/about">Learn More</a>

{{-- I use a reusable component: --}}
<x-button href="/about" variant="primary" icon="ri-arrow-right-up-line">
    Learn More
</x-button>
Enter fullscreen mode Exit fullscreen mode

The <x-button> component encapsulates all button variants, icon positioning, and styling logic. One change to the component updates every button across the entire site. The same pattern applies to pagination, cards, headers, and footers.

The Deployment Layer

Architecture doesn't end at the code level. My deployment pipeline is part of the architecture:

git push → GitHub → Deployer SSH trigger
→ composer install --no-dev --optimize-autoloader
→ npm run build
→ php artisan config:cache
→ php artisan route:cache
→ php artisan view:cache
→ atomic symlink swap (ln -sfn)
→ php artisan cache:clear

The symlink swap is kernel-level atomic. There is no moment where the server serves a broken build. Rollback is a single command: dep rollback.

What This Architecture Gives You

Concern
How This Blueprint Solves It

Testability
Services behind interfaces → mock anything

Maintainability
Single responsibility → one reason to change per class

Onboarding
Predictable structure → new developers find things fast

Swappability
Interface bindings → swap data sources without rewrites

Consistency
Blade components → UI changes propagate globally

Performance
Cache layer in service → controllers never touch I/O directly

Deployability
Atomic deploys → zero downtime, instant rollback

The Takeaway

The architecture isn't clever. It's deliberately boring. Every design pattern used here — service layer, interface segregation, view composers, transformers, component system — is well-documented and widely understood. The discipline is in applying them consistently on every project, not just when the codebase gets "big enough."

The codebase behind klytron.com uses every pattern described in this post. It's a production system that I maintain, extend, and deploy to regularly. The patterns translate directly to any Laravel project — whether it's a content site, a SaaS platform, or an enterprise API.

If you're building a Laravel application and the controller methods are getting long, the answer is almost always the same: extract a service, define an interface, and let the controller be thin.

Have questions about structuring your Laravel project? I consult on application architecture and can audit existing codebases to identify structural improvements. Get in touch.


Originally published on klytron.com

Top comments (0)