Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 009 / 100

Laravel project structure explained: a step-by-step guide

Where everything lives in a Laravel app.

Expona Admin8 min read

Introduction

When you first open a Laravel project, you're greeted with a folder structure that can feel overwhelming. Directories like app/, routes/, database/, and config/ each serve a distinct purpose, and understanding where everything lives is essential for writing maintainable, scalable code. In this guide, we'll walk through Laravel project structure explained: a step-by-step guide, so you know exactly where to place your models, controllers, services, routes, and configuration files.

The app/ Directory: Where Your Business Logic Lives

The app/ directory is the heart of your Laravel application. This is where you'll spend most of your time writing code that defines your domain logic, handles HTTP requests, and manages data transformations. Understanding what goes where inside app/ makes the difference between a codebase that scales and one that becomes a tangled mess.

Models

Models live in app/Models/ and represent your database tables and their relationships. A model encapsulates the data and behavior of a single entity. For example, if you're building an e-commerce platform, you'd have models like Product, Order, and Customer. Each model connects to its corresponding database table and defines relationships to other models.

php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Store extends Model
{
    protected $fillable = ['name', 'slug', 'owner_id'];

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    public function getActiveProducts()
    {
        return $this->products()->where('active', true)->get();
    }
}

Notice we're putting a small business method on the model itself. Don't overload models, though—if the logic gets complex, that's a signal to create a service class.

Controllers

Controllers live in app/Http/Controllers/ and handle incoming HTTP requests. They coordinate between routes, models, and services. A well-written controller is thin—it orchestrates but doesn't contain business logic. Controllers receive requests, validate input, call services or query models, and return responses.

php
namespace App\Http\Controllers;

use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function __construct(private ProductService $productService) {}

    public function show(Product $product)
    {
        return response()->json($product->load('store'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'price' => 'required|numeric|min:0',
        ]);

        $product = $this->productService->create($validated);

        return response()->json($product, 201);
    }
}

The controller validates the input, calls a service to handle the business logic, and returns a response. It stays focused and readable.

Services

Services live in app/Services/ and contain your application's business logic. This is where complex operations live—calculations, external API calls, multi-step workflows, and transactions. Services are reusable across controllers, commands, and queued jobs. When a controller is doing too much, or when you have logic that multiple parts of your app need to access, a service is the answer.

php
namespace App\Services;

use App\Models\Product;
use Illuminate\Support\Collection;

class ProductService
{
    public function create(array $data): Product
    {
        $product = Product::create($data);
        $this->syncInventory($product);
        return $product;
    }

    public function getRecommendations(Product $product): Collection
    {
        return $product->store
            ->getActiveProducts()
            ->where('id', '!=', $product->id)
            ->where('category', $product->category)
            ->take(5)
            ->get();
    }

    private function syncInventory(Product $product): void
    {
        // Complex inventory sync logic here
    }
}

Services are dependency-injected into controllers and can themselves depend on other services, repositories, or external clients. This structure keeps your code testable and flexible.

The routes/ Directory: Defining Your Application's Endpoints

The routes/ directory contains the files that map HTTP requests to your controllers. Laravel separates web routes from API routes by default, which is a sensible pattern that keeps them logically distinct.

Web Routes

Web routes typically serve HTML responses and are defined in routes/web.php. They often use sessions, CSRF protection, and cookies. If you're building a traditional server-rendered application with Blade templates, your routes go here.

php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::get('/products', [ProductController::class, 'index'])->name('products.index');
    Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
    Route::post('/products', [ProductController::class, 'store'])->name('products.store');
});

API Routes

API routes are defined in routes/api.php and return JSON responses. They're automatically prefixed with /api/ by the RouteServiceProvider. If you're building a REST API consumed by a frontend framework like React or Next.js, your routes go here.

php
use App\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
    Route::get('/products/{product}', [ProductController::class, 'show']);
    Route::post('/products', [ProductController::class, 'store']);
    Route::put('/products/{product}', [ProductController::class, 'update']);
    Route::delete('/products/{product}', [ProductController::class, 'destroy']);
});

Keeping API routes separate makes it easy to apply different middleware, versioning strategies, and rate limits to your API without affecting your web routes.

The database/ Directory: Migrations, Factories, and Seeders

The database/ directory manages everything related to your database schema and test data. This is where you define your tables, populate them with dummy data, and create realistic test factories.

Migrations

Migrations in database/migrations/ are version-controlled SQL schema changes. Each migration file represents a single step in building or modifying your database. They're timestamped so Laravel knows the order to run them. Migrations are the source of truth for your schema and make it simple to sync databases across your team.

php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained();
            $table->string('name');
            $table->decimal('price', 10, 2);
            $table->boolean('active')->default(true);
            $table->timestamps();
            $table->index('store_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Factories and Seeders

Factories in database/factories/ generate fake data for testing. Seeders in database/seeders/ populate your database with initial or test data. Together, they make it simple to set up consistent test environments across your team.

php
// database/factories/ProductFactory.php
namespace Database\Factories;

use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

class ProductFactory extends Factory
{
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => $this->faker->words(3, true),
            'price' => $this->faker->randomFloat(2, 10, 500),
            'active' => true,
        ];
    }
}

// database/seeders/ProductSeeder.php
namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Seeder;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        Product::factory()->count(100)->create();
    }
}

Run php artisan migrate --seed and your database is populated with realistic test data. This speeds up development and testing considerably.

The config/ Directory: Application Configuration

Configuration files in config/ define how your application behaves. These are PHP files that return arrays of settings. They're loaded at boot time and you access them via the config() helper function. Laravel provides sensible defaults, but you'll often customize them for your specific needs.

Reading from .env

Configuration values often come from your .env file, which contains environment-specific settings like database credentials, API keys, and feature flags. The .env file is never committed to version control—each environment has its own.

php
// .env
DB_CONNECTION=pgsql
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=ecommerce
DB_USERNAME=postgres
DB_PASSWORD=secret

STRIPE_SECRET_KEY=sk_test_xyz123
REDIS_HOST=localhost
REDIS_PORT=6379

Inside your config files, you reference environment variables using env(). This separation keeps sensitive credentials out of your codebase.

php
// config/database.php
return [
    'default' => env('DB_CONNECTION', 'sqlite'),

    'connections' => [
        'pgsql' => [
            'driver' => 'pgsql',
            'host' => env('DB_HOST', 'localhost'),
            'port' => env('DB_PORT', 5432),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
        ],
    ],
];

// In your service provider or controller
$dbHost = config('database.connections.pgsql.host');

The config() helper lets you access nested configuration values anywhere in your application. This approach keeps your codebase environment-agnostic and deployable to different infrastructure with just an .env file change.

Other Important Directories

While we've focused on the core directories, Laravel's structure includes several others worth mentioning:

  • app/Http/Middleware/ — HTTP middleware that runs before or after your controller logic, like authentication checks or CORS handling.
  • app/Exceptions/ — Custom exception classes and the exception handler that formats errors for responses.
  • app/Jobs/ — Queued jobs for long-running operations like sending emails or processing payments asynchronously.
  • resources/views/ — Blade template files if you're serving HTML from your Laravel application.
  • storage/ — File uploads, logs, and cache files that your application generates at runtime.
  • tests/ — Unit and feature tests that verify your application's behavior.

Common Mistakes

Putting business logic in controllers. Controllers should be thin. If you're doing complex calculations, querying multiple models, or calling external services, move it to a service. Your code becomes testable, reusable, and maintainable.

Overloading models with methods. Models can grow bloated if you add every possible query or operation to them. Use scopes for common queries and extract complex logic into services or repository classes.

Hardcoding configuration values. Never hardcode API keys, database passwords, or environment-specific settings in your code. Always read from .env via config files. This keeps your codebase portable and secure.

Mixing web and API concerns. Keep your routes/web.php and routes/api.php separate. They serve different purposes and often need different middleware or versioning strategies. Mixing them creates confusion and makes refactoring harder.

Skipping migrations or factories. It's tempting to manually create your database schema and populate test data by hand. Don't. Migrations and factories are version-controlled, repeatable, and part of your development workflow. They make onboarding new team members and setting up new environments trivial.

Key Takeaways

  • The app/ directory contains your models, controllers, and services—the core logic of your application.
  • Controllers should orchestrate requests and delegate business logic to services; keep them thin and focused.
  • Services contain reusable business logic and complex operations that multiple parts of your application need.
  • Routes in routes/web.php and routes/api.php map HTTP requests to your controllers; keep them logically separate.
  • Migrations, factories, and seeders in database/ make your schema and test data version-controlled and reproducible.
  • Configuration lives in config/ and reads from .env; never hardcode environment-specific values in your code.
  • Understanding this structure helps you write scalable, maintainable Laravel applications that grow gracefully over time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles