Skip to main content
Available for Projects

I'm always excited to take on new projects and collaborate with innovative minds.

Laravel

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

After 16+ years of building production systems, I have a specific way I organise Laravel codebases for maintainability, testability, and long-term sanity. This is the exact blueprint I follow — services, interfaces, view composers, content pipelines — with real code from a live production site.

Michael K. Laweh
2026-04-11 22:00:00 14 min read
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/

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)

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;
}

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()
        );
    }
}

Why the interface matters

In tests, I can swap ContentService for a fake:

$this->app->bind(ContentServiceInterface::class, FakeContentService::class);

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'));
}

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);
    }
}

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'),
        ];
    }
}

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' => '[email protected]',
        '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'],
    ],
];

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 { /* ... */ }
}

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>

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.

Michael K. Laweh
Michael K. Laweh
Author

Senior IT Consultant & Digital Solutions Architect with 16+ years of engineering experience. Founder of LAWEITECH, builder of ScrybaSMS, Nexus Retail OS, and 9 open-source packages. Currently building the next generation of AI-integrated enterprise tools.

Have a project in mind?

From AI-integrated platforms to enterprise infrastructure, I architect solutions that deliver measurable business results. Let's talk.

Post Details
Read Time 14 min read
Published 2026-04-11 22:00:00
Category Laravel
Author Michael K. Laweh
Share Article

Related Articles

View All Posts
Mar 28, 2026 • 12 min read
How I Built a Markdown-Powered CMS in Laravel With Zero Database

A deep-dive into the flat-file content architecture powering klytron.c...

Aug 13, 2025 • 8 min read
Laravel Google Drive Filesystem: Unlimited Cloud Storage with Familiar Syntax

Stop choosing between affordable storage and elegant code. This open-s...

Jul 18, 2025 • 5 min read
Supercharge Your Laravel Scheduler: Send Job Outputs to Telegram Instantly

Stop digging through log files to check if your backups ran. This open...