DEV Community

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

Posted on • Edited 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 10+ 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 my own site from a database-backed CMS to a flat-file Markdown CMS, and the interface boundary is what made that migration incredibly clean and painless.


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 external API. It asks the service, gets data, and 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 — colors, fonts, menu items, social links, and footer content. Passing this through every controller method is tedious, error-prone, and violates the DRY principle.

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);
Enter fullscreen mode Exit fullscreen mode

Now every single view in the application has access to $theme without any controller intervention. Menu items, social links, primary colors — 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 completely reusable.


Principle 5: Configuration-Driven Architecture

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

// config/site.php
return [
    'url'     => env('SITE_URL', 'https://klytron.com'),
    'author'  => [
        'name'  => 'Michael K. Laweh',
        'email' => 'email@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
Enter fullscreen mode Exit fullscreen mode

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, and the 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.

Ready to inspect the complete blueprint?

👉 Read the complete deep-dive with the full code repository and bonus security checklist on klytron.com

Top comments (0)