Mastering Laravel Project Structure: Blade Layouts, Partials, Vite Assets & Clean Folder Organization

Nawjesh Soyeb Best Practices Blade Laravel Project Structure Vite

Laravel doesn't force much structure, so two projects can both 'work' while looking completely different — and that gap shows up the moment someone new opens the codebase. This guide lays out a Blade layout hierarchy (master → app/admin), focused partials, Vite entry points split by area, lean models, centralized helpers, and Form Request validation, with a full folder tree you can copy directly into your own project.

Laravel doesn't force much structure beyond the basics, which means two developers can build wildly different-looking projects that both "work." The problem shows up later — when a new team member joins, or you return to your own project after six months and can't find anything. This guide lays out a folder and file structure that scales, keeps Blade views organized, and separates concerns the way Laravel is designed to be used.

Why Structure Matters More Than It Seems

A messy project isn't just ugly — it costs real time:

  • Duplicate blade markup because there was no shared layout to extend
  • Business logic buried inside controllers instead of models or dedicated classes
  • No clear place to put a helper function, so it ends up pasted in three different files
  • Asset files scattered without a build pipeline, causing manual cache-busting headaches

None of this requires a package or a framework change — just consistent conventions.

Blade Layout Structure

Split your layouts by purpose instead of having one giant app.blade.php with conditionals everywhere.

resources/views/
├── layouts/
│   ├── app.blade.php        (public-facing site layout)
│   ├── admin.blade.php      (admin panel layout)
│   └── master.blade.php     (shared base both extend from)
├── partials/
│   ├── _header.blade.php
│   ├── _footer.blade.php
│   ├── _navbar.blade.php
│   ├── _sidebar.blade.php
│   └── _flash-messages.blade.php
├── components/
│   ├── button.blade.php
│   └── card.blade.php
├── admin/
│   ├── dashboard.blade.php
│   └── users/
│       ├── index.blade.php
│       └── edit.blade.php
└── blog/
    ├── index.blade.php
    └── show.blade.php

layouts/master.blade.php — the true base, holds only what's common to every page (doctype, head, meta tags, scripts):

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>@yield('title', config('app.name'))</title>
    <meta name="description" content="@yield('meta_description', '')">
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('styles')
</head>
<body>
    @yield('content')
    @stack('scripts')
</body>
</html>

layouts/app.blade.php — extends master, adds the public site chrome:

@extends('layouts.master')

@section('content')
    @include('partials._navbar')
    @include('partials._flash-messages')

    <main>
        @yield('body')
    </main>

    @include('partials._footer')
@endsection

layouts/admin.blade.php — extends master, adds admin chrome instead:

@extends('layouts.master')

@section('content')
    <div class="admin-wrapper">
        @include('partials._sidebar')

        <div class="admin-main">
            @include('partials._header')
            @include('partials._flash-messages')

            <div class="admin-content">
                @yield('body')
            </div>
        </div>
    </div>
@endsection

A page then only extends the layout it needs:

{{-- resources/views/blog/show.blade.php --}}
@extends('layouts.app')

@section('title', $post->meta_title ?? $post->title)
@section('meta_description', $post->meta_description ?? $post->excerpt)

@section('body')
    <article>
        <h1>{{ $post->title }}</h1>
        {!! $post->body !!}
    </article>
@endsection

Partials: Keep Them Focused

Each partial should do exactly one job. _flash-messages.blade.php is a good example of a small, reusable partial that both layouts include:

{{-- resources/views/partials/_flash-messages.blade.php --}}
@if (session('success'))
    <div class="alert alert-success">{{ session('success') }}</div>
@endif

@if (session('error'))
    <div class="alert alert-error">{{ session('error') }}</div>
@endif

@if ($errors->any())
    <div class="alert alert-error">
        <ul>
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

Naming convention worth adopting: prefix true partials (not meant to be used standalone) with an underscore — _header.blade.php, _sidebar.blade.php — so it's visually obvious in the file list which files are fragments versus full pages.

Blade Components vs Partials

Use @include for simple, static-ish fragments (header, footer, flash messages). Use Blade components when the fragment needs props or reusable logic:

{{-- resources/views/components/button.blade.php --}}
@props(['type' => 'primary', 'href' => null])

@if ($href)
    <a href="{{ $href }}" {{ $attributes->merge(['class' => "btn btn-$type"]) }}>
        {{ $slot }}
    </a>
@else
    <button {{ $attributes->merge(['class' => "btn btn-$type"]) }}>
        {{ $slot }}
    </button>
@endif

Usage anywhere in the project:

<x-button type="primary" href="{{ route('contact') }}">Get a Quote</x-button>

Vite Asset Structure

Keep source assets separated by concern rather than dumping everything into one app.js:

resources/
├── css/
│   ├── app.css              (imports below, entry point)
│   ├── admin.css            (admin-only styles, separate entry)
│   └── partials/
│       ├── _variables.css
│       └── _buttons.css
├── js/
│   ├── app.js                (public site entry point)
│   ├── admin.js               (admin panel entry point)
│   └── modules/
│       ├── flash-messages.js
│       └── mobile-nav.js
└── views/

vite.config.js with multiple entry points for public vs admin bundles, so admin JS/CSS never loads on public pages:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: [
                'resources/css/app.css',
                'resources/js/app.js',
                'resources/css/admin.css',
                'resources/js/admin.js',
            ],
            refresh: true,
        }),
    ],
});

Then in each layout, load only what that layout needs:

{{-- layouts/app.blade.php head --}}
@vite(['resources/css/app.css', 'resources/js/app.js'])

{{-- layouts/admin.blade.php head --}}
@vite(['resources/css/admin.css', 'resources/js/admin.js'])

Models: Keep Them Lean but Complete

A model should hold relationships, scopes, accessors/mutators, and casts — not raw query logic scattered from controllers.

app/Models/
├── Post.php
├── Tag.php
└── User.php
// app/Models/Post.php
class Post extends Model
{
    protected $fillable = ['title', 'slug', 'excerpt', 'body', 'status', 'published_at'];

    protected $casts = [
        'published_at' => 'datetime',
    ];

    public function author()
    {
        return $this->belongsTo(User::class, 'author_id');
    }

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                     ->where('published_at', '<=', now());
    }
}

Helpers: One Place, Not Three

Create a dedicated app/Helpers folder and autoload it via Composer instead of scattering global functions across random files.

app/Helpers/
└── helpers.php
// app/Helpers/helpers.php
if (! function_exists('reading_time')) {
    function reading_time(string $text): int
    {
        $words = str_word_count(strip_tags($text));
        return max(1, (int) ceil($words / 200));
    }
}

Register it in composer.json:

"autoload": {
    "files": [
        "app/Helpers/helpers.php"
    ]
}

Then run composer dump-autoload once after adding it.

Form Requests: Validation Out of Controllers

Instead of validating inline inside a controller method, use dedicated Form Request classes:

app/Http/Requests/
├── StorePostRequest.php
└── UpdatePostRequest.php
// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'title' => 'required|string|max:255',
            'excerpt' => 'nullable|string|max:500',
            'body' => 'required|string',
            'status' => 'required|in:draft,published',
        ];
    }
}

The controller stays thin:

public function store(StorePostRequest $request)
{
    Post::create($request->validated());
    return redirect()->route('admin.blog.index')->with('success', 'Post created.');
}

Full Recommended Directory Overview

app/
├── Helpers/
│   └── helpers.php
├── Http/
│   ├── Controllers/
│   │   ├── BlogController.php
│   │   └── Admin/
│   │       └── PostController.php
│   └── Requests/
│       ├── StorePostRequest.php
│       └── UpdatePostRequest.php
├── Models/
│   ├── Post.php
│   ├── Tag.php
│   └── User.php
resources/
├── css/
├── js/
└── views/
    ├── layouts/
    │   ├── master.blade.php
    │   ├── app.blade.php
    │   └── admin.blade.php
    ├── partials/
    │   ├── _header.blade.php
    │   ├── _footer.blade.php
    │   ├── _navbar.blade.php
    │   ├── _sidebar.blade.php
    │   └── _flash-messages.blade.php
    ├── components/
    ├── admin/
    └── blog/
routes/
├── web.php
└── admin.php

Quick Checklist

  • ✓ Base layout (master.blade.php) holds only truly shared markup
  • ✓ Separate layouts for public site vs admin panel, both extending the base
  • ✓ Partials prefixed with _ and each doing one job
  • ✓ Blade components used for anything needing props, not just static includes
  • ✓ Vite entry points split by area (public vs admin) so bundles stay small
  • ✓ Models hold relationships, scopes, and casts — not raw queries from controllers
  • ✓ Helpers centralized in one autoloaded file, not duplicated across the codebase
  • ✓ Validation lives in Form Request classes, not inline in controllers

None of this is Laravel-enforced — it's convention. But once a project follows it consistently, any developer (including future-you) can open the folder tree and know exactly where something lives before even opening a file.