Convention Over Configuration in Laravel — How Laravel Thinks For You

If you've used Laravel even briefly, you've noticed something unusual. You write surprisingly little configuration code. You don't tell Laravel where your models are, what your table names are, or how your foreign keys connect — it just figures it out. This isn't magic. It's a deliberate design philosophy called Convention Over Configuration, and once you understand it deeply, the entire framework starts making sense in a new way.


What Does Convention Over Configuration Actually Mean

Most frameworks require you to explicitly configure everything. You tell the system where your files are, what your tables are named, how relationships connect. You write configuration before you write logic.

Convention over configuration flips this. The framework decides a set of standard rules upfront — conventions. As long as you follow those conventions, zero configuration is needed. You only write configuration when you deliberately want to break from the standard.

Laravel borrowed this philosophy from Ruby on Rails, which popularized it in 2004. Laravel applies it across nearly every layer of the framework.


Convention 1 — Models and Table Names

Create a model called Post. Laravel automatically assumes the database table is called posts.

php artisan make:model Post

    <?php

    namespace App\Models;

    use Illuminate\Database\Eloquent\Model;

    class Post extends Model
    {

    }

That's the entire model. No table name specified. Laravel applies the convention:

Model name → Lowercase → Plural → Table name
Post        → post      → posts  → posts table

More examples:

Model Name

Assumed Table

User

users

BlogPost

blog_posts

OrderItem

order_items

Category

categories

Person

people

Laravel uses proper English pluralization — it knows Person becomes people, not persons.

Breaking the Convention

If your table is named differently for any reason, just declare it explicitly:


    class Post extends Model
    {
        protected $table = 'articles';
    }

Now Laravel uses articles instead of posts. Convention ignored for this model only.


Convention 2 — Primary Keys

Every table is assumed to have a primary key column named id of type bigint unsigned with auto increment.


    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->timestamps();
    });

$table->id() creates a column named id — and Laravel's Eloquent automatically knows this is the primary key without you specifying anything.

Breaking the Convention


    class Post extends Model
    {
        protected $primaryKey = 'post_id';
        public $incrementing = false;
        protected $keyType = 'string';
    }

Now Laravel uses post_id as the primary key, treats it as non-incrementing, and expects a string type like a UUID.

Convention 3 — Foreign Keys and constrained()

This is where the convention becomes most visually impressive. When you define a foreign key column, the name you give it tells Laravel everything:


    $table->foreignId('user_id')->constrained();

Laravel reads user_id and applies the convention:

Column name → Strip_id → Plural → Table name → id column
user_id     → user      → users  → users.id

So constrained() with no arguments automatically creates a foreign key constraint pointing to users.id. You wrote one word. Laravel figured out the entire relationship.


    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained();
        $table->foreignId('category_id')->constrained();
        $table->string('title');
        $table->timestamps();
    });

Two foreign keys, zero explicit table references. Laravel derived both:

  • user_idusers.id
  • category_idcategories.id

Breaking the Convention

When your column name doesn't follow the pattern, tell Laravel explicitly:


    $table->foreignId('author_id')->constrained('users');
    $table->foreignId('approved_by')->constrained('users', 'id');
    $table->foreignId('parent_post_id')->constrained('posts');

author_id would make Laravel look for an authors table that doesn't exist. Passing 'users' overrides that assumption.


Convention 4 — Timestamps

Every migration you generate includes this line by default:


    $table->timestamps();

This creates two columns — created_at and updated_at. Eloquent automatically manages both. When you create a record, created_at is set. When you update it, updated_at is updated. You never touch these columns manually.


    $post = Post::create(['title' => 'My First Post']);

    echo $post->created_at;
    echo $post->updated_at;

Both are populated automatically because Laravel follows the convention that these two columns exist and have these exact names.

Breaking the Convention


    class Post extends Model
    {
        public $timestamps = false;
    }

Or use custom column names:


    class Post extends Model
    {
        const CREATED_AT = 'created_date';
        const UPDATED_AT = 'modified_date';
    }


Convention 5 — Eloquent Relationships

Relationships follow the same naming logic. When you define a belongsTo relationship:


    class Post extends Model
    {
        public function user()
        {
            return $this->belongsTo(User::class);
        }
    }

Laravel assumes the foreign key on the posts table is user_id — derived from the User model name. You don't pass the key name. The convention handles it.


    class User extends Model
    {
        public function posts()
        {
            return $this->hasMany(Post::class);
        }
    }

hasMany assumes the foreign key on posts is user_id — derived from the current model name User.

Full Relationship Example


    class Comment extends Model
    {
        public function post()
        {
            return $this->belongsTo(Post::class);
        }

        public function user()
        {
            return $this->belongsTo(User::class);
        }
    }

Laravel assumes:

  • comments table has post_id → references posts.id
  • comments table has user_id → references users.id

Zero configuration. Just model names and Laravel fills in the rest.

Breaking the Convention


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

Method is named author, but the actual foreign key is author_id and it points to User. Laravel can't derive this from the method name alone, so you pass the key explicitly.


Convention 6 — Controllers and Resource Naming

When you generate a resource controller:

php artisan make:controller PostController --resource

Laravel generates seven methods following REST conventions:

Method

Route

HTTP Verb

Purpose

index

/posts

GET

List all posts

create

/posts/create

GET

Show create form

store

/posts

POST

Save new post

show

/posts/{post}

GET

Show one post

edit

/posts/{post}/edit

GET

Show edit form

update

/posts/{post}

PUT/PATCH

Update post

destroy

/posts/{post}

DELETE

Delete post

Register all seven with one line:


    Route::resource('posts', PostController::class);

The naming convention between controller method names and HTTP verbs is baked in. Laravel knows store means POST, destroy means DELETE, without you mapping anything.


Convention 7 — View Files and the view() Helper


    return view('posts.index');

Laravel looks for this file at:

resources/views/posts/index.blade.php

The dot notation maps directly to folder structure. posts.index means posts folder, index file, .blade.php extension assumed automatically.


    return view('admin.posts.edit');
    // resources/views/admin/posts/edit.blade.php


    return view('emails.welcome');
    // resources/views/emails/welcome.blade.php

No path configuration, no extension specification. Convention handles it.


Convention 8 — Route Model Binding

This is one of the most elegant conventions in Laravel. Define a route with a parameter that matches a model name:


    Route::get('/posts/{post}', [PostController::class, 'show']);

Then type-hint it in your controller:


    public function show(Post $post)
    {
        return view('posts.show', compact('post'));
    }

Laravel sees that the parameter is named post and the type-hint is Post model. It automatically queries the database for Post::find($post) and injects the model instance. If the record doesn't exist, it returns a 404 automatically.

No manual Post::find($id) call. No abort(404) check. Convention does it all.


The Full Convention Map

What Laravel Derives

From What

Table name (posts)

Model name (Post)

Primary key (id)

Default assumption

Foreign key (user_id)

belongsTo(User::class)

Referenced table (users)

Column name (user_id)

Timestamps management

created_at / updated_at columns

View file path

Dot notation string

Route model

Parameter name + type-hint

Resource routes

Controller method names


Why This Philosophy Matters

Every convention Laravel follows removes a decision you have to make. In a large application with dozens of models, controllers, and relationships, those removed decisions add up to thousands of lines of configuration you never had to write.

More importantly, conventions create consistency across teams. A new developer joining a Laravel project already knows where models are, what tables are named, how foreign keys are structured — because every Laravel project follows the same rules. The codebase is predictable before they read a single line.

The configuration option always exists when you need it. But the goal is to need it as rarely as possible.


The Bottom Line

Convention over configuration is not a shortcut or a limitation. It's a contract between you and the framework. Laravel promises to handle all the boilerplate as long as you follow its naming rules. In return, you write less code, make fewer decisions, and maintain more consistent codebases. Understanding the conventions deeply — not just following them blindly — is what separates a developer who uses Laravel from a developer who understands it.

PHP & Laravel — Zero to Hero Episode 16: Routing and Migrations — Defining URLs and Building Your Database With Code

What Are We Doing in This Post?

In Episode 15 we installed Laravel and understood the folder structure. We saw a basic route in action — a URL mapped to a response in routes/web.php.

In this episode we go deep into two of the most fundamental Laravel features.

Routing — the system that decides what happens when a user visits a URL. Every URL your application responds to is defined through routing.

Migrations — the system that defines your database table structure in PHP code. Instead of creating tables manually in phpMyAdmin, you write a migration file and Laravel creates the table for you with one command.

These two features are the backbone of every Laravel application. Let us go deep.


Part 1 — Routing

What is a Route?

A route is a mapping between a URL and what should happen when that URL is visited.

Real world analogy: Think of routes like a reception desk at a large office building. Every visitor who walks in says where they want to go. The receptionist checks the directory and says — "Third floor, room 302." The route is that directory entry. The URL is the visitor's destination request. Laravel checks the routes file and figures out what to do with that request.

Without routes, Laravel has no idea what to do when someone visits a URL. Every single URL your application responds to must be defined in routes/web.php.


Basic Route Syntax

Open routes/web.php and look at the structure:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/', function () {
        return "Hello from Laravel!";
    });

Route::get() registers a route that responds to HTTP GET requests. The first argument is the URL path. The second argument is what to do when that URL is visited — currently a closure (an anonymous function).

Laravel supports all HTTP methods:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/page', function () {
        return "This handles GET requests";
    });

    Route::post('/page', function () {
        return "This handles POST requests — form submissions";
    });

    Route::put('/page', function () {
        return "This handles PUT requests — full updates";
    });

    Route::patch('/page', function () {
        return "This handles PATCH requests — partial updates";
    });

    Route::delete('/page', function () {
        return "This handles DELETE requests";
    });

In a real application, GET is used for displaying pages, POST for creating data, PUT/PATCH for updating data, and DELETE for removing data.


Route Parameters — Dynamic URLs

Real websites have dynamic URLs. A blog post URL like /posts/15 or a user profile like /users/gagan. The number or name changes per request — but the route structure is the same.

Route parameters let you capture that dynamic part of the URL.


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/users/{id}', function ($id) {
        return "Showing user with ID: " . $id;
    });

    Route::get('/posts/{slug}', function ($slug) {
        return "Showing post: " . $slug;
    });

Visit http://127.0.0.1:8000/users/42 — you see: Showing user with ID: 42

Visit http://127.0.0.1:8000/users/99 — you see: Showing user with ID: 99

The {id} in the route becomes the $id parameter in the function automatically. Whatever is in that URL segment gets passed directly.

Multiple parameters:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/categories/{category}/posts/{id}', function ($category, $id) {
        return "Category: $category | Post ID: $id";
    });

Visit http://127.0.0.1:8000/categories/technology/posts/5 — you see: Category: technology | Post ID: 5

Optional parameters:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/products/{category?}', function ($category = "all") {
        return "Showing products in category: " . $category;
    });

The ? makes the parameter optional. If the user visits /products, the default value "all" is used. If they visit /products/laptops, $category becomes "laptops".


Route Constraints — Validating URL Parameters

You can restrict what values a route parameter accepts using where().


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/users/{id}', function ($id) {
        return "User ID: " . $id;
    })->where('id', '[0-9]+');

    Route::get('/posts/{slug}', function ($slug) {
        return "Post: " . $slug;
    })->where('slug', '[a-z\-]+');

The first route only matches if {id} is one or more digits. Visiting /users/abc will return a 404 — because abc does not match [0-9]+.

The second route only matches if {slug} contains only lowercase letters and hyphens.

This is important for security and correctness — you do not want someone passing arbitrary strings where you expect a numeric ID.


Named Routes — Giving Routes a Name

Instead of hardcoding URLs throughout your application, you give routes names and reference them by name. This way if you ever change a URL, you only change it in one place — the route definition — and everywhere that uses the route name automatically updates.


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/user/profile', function () {
        return "User profile page";
    })->name('profile');

    Route::get('/dashboard', function () {
        return "Dashboard page";
    })->name('dashboard');

    Route::get('/posts/{id}', function ($id) {
        return "Post number: " . $id;
    })->name('posts.show');

Now you can generate URLs using route names anywhere in your application:


    <?php

    $url = route('profile');
    $url = route('posts.show', ['id' => 15]);

In Blade templates you will use:


    <a href="{{ route('profile') }}">My Profile</a>
    <a href="{{ route('posts.show', ['id' => 15]) }}">Read Post</a>

This is standard practice in all Laravel applications. Never hardcode URLs — always use named routes.


Route Groups — Organizing Related Routes

When multiple routes share a common prefix or middleware, you group them instead of repeating the same configuration on every route.

Prefix grouping:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::prefix('admin')->group(function () {
        Route::get('/dashboard', function () {
            return "Admin Dashboard";
        });

        Route::get('/users', function () {
            return "Admin Users List";
        });

        Route::get('/settings', function () {
            return "Admin Settings";
        });
    });

These three routes respond to /admin/dashboard, /admin/users, and /admin/settings. The prefix admin is defined once on the group — not repeated on every route.

Named prefix grouping:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::prefix('admin')->name('admin.')->group(function () {
        Route::get('/dashboard', function () {
            return "Admin Dashboard";
        })->name('dashboard');

        Route::get('/users', function () {
            return "Admin Users";
        })->name('users');
    });

Now the routes are named admin.dashboard and admin.users. Clean, organized, and consistent.


Viewing All Registered Routes

Laravel gives you an Artisan command to see every route your application has registered:

php artisan route:list

Run this now. You will see a table with the HTTP method, URI, name, and action for every route. This is one of the most useful debugging tools in Laravel — when a route is not working as expected, this command tells you exactly what Laravel knows about.


Part 2 — Migrations

What is a Migration?

A migration is a PHP file that defines a database table's structure in code.

Real world analogy: Think of a migration like an instruction manual for building a specific piece of furniture. The manual describes exactly what pieces to use, what dimensions to cut, how to assemble everything. Your colleague can take that same instruction manual, follow it step by step, and build an identical piece of furniture. Migrations do the same thing — any developer on your team runs php artisan migrate and gets an identical database structure, no matter what machine they are on.

Before migrations, developers would manually create tables in phpMyAdmin and then email each other SQL files to run. This was error-prone, unversioned, and chaos in team environments.

With migrations, your database structure lives in code, inside version control, alongside your application. It is the only professional way to manage databases in modern PHP development.


The Migration Files Laravel Created

Open database/migrations/ in VS Code. You will see three files already there:

0001_01_01_000000_create_users_table.php
0001_01_01_000001_create_cache_table.php
0001_01_01_000002_create_jobs_table.php

Open 0001_01_01_000000_create_users_table.php and look at the structure:


    <?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('users', function (Blueprint $table) {
                $table->id();
                $table->string('name');
                $table->string('email')->unique();
                $table->timestamp('email_verified_at')->nullable();
                $table->string('password');
                $table->rememberToken();
                $table->timestamps();
            });
        }

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

Every migration has two methods.

up() runs when you execute php artisan migrate — it creates or modifies the table.

down() runs when you execute php artisan migrate:rollback — it undoes whatever up() did. This is your undo button.

Inside up(), Schema::create() takes the table name and a closure. Inside the closure, $table is a Blueprint object — it has methods for every column type you could need.

$table->id() creates an auto-incrementing unsigned big integer primary key column named id.

$table->string('name') creates a VARCHAR(255) column.

$table->string('email')->unique() creates a VARCHAR(255) column with a unique constraint — no two rows can have the same email.

$table->timestamp('email_verified_at')->nullable() creates a timestamp column that can be null.

$table->rememberToken() creates a remember_token VARCHAR(100) column — used for "remember me" login functionality.

$table->timestamps() creates two columns automatically — created_at and updated_at. Laravel updates these automatically when records are created or modified.


Creating Your First Custom Migration

Let us create a migration for a posts table — the kind you would use for a blog application.

Run this Artisan command:

php artisan make:migration create_posts_table

Laravel creates a new migration file in database/migrations/ with a timestamp in the filename. Open it:


    <?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('posts', function (Blueprint $table) {

            });
        }

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

Now fill in the up() method with the columns your posts table needs:


    <?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('posts', function (Blueprint $table) {
                $table->id();
                $table->foreignId('user_id')->constrained()->onDelete('cascade');
                $table->string('title');
                $table->string('slug')->unique();
                $table->text('body');
                $table->string('status')->default('draft');
                $table->unsignedInteger('views')->default(0);
                $table->timestamp('published_at')->nullable();
                $table->timestamps();
            });
        }

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

Let us understand each column.

$table->id() — primary key, auto-increment.

$table->foreignId('user_id')->constrained()->onDelete('cascade') — creates a user_id column and a foreign key constraint pointing to the users table's id column. onDelete('cascade') means if a user is deleted, all their posts are automatically deleted too.

$table->string('title') — VARCHAR(255) for the post title.

$table->string('slug')->unique() — URL-friendly version of the title, must be unique. Like my-first-post.

$table->text('body') — TEXT column for the full post content. Unlike string, text has no length limit.

$table->string('status')->default('draft') — post status, defaults to draft. Can be changed to published.

$table->unsignedInteger('views')->default(0) — view counter, starts at 0, cannot be negative.

$table->timestamp('published_at')->nullable() — when the post was published. Null means not published yet.

$table->timestamps()created_at and updated_at managed automatically by Laravel.


Common Column Types

Here are the most used Blueprint column methods you will use in real projects:


    <?php

    $table->id();
    $table->string('name');
    $table->string('email', 100);
    $table->text('description');
    $table->longText('content');
    $table->integer('age');
    $table->unsignedInteger('views');
    $table->bigInteger('file_size');
    $table->float('rating');
    $table->decimal('price', 8, 2);
    $table->boolean('is_active');
    $table->date('birth_date');
    $table->timestamp('published_at');
    $table->timestamps();
    $table->softDeletes();
    $table->foreignId('user_id')->constrained();
    $table->json('metadata');
    $table->enum('status', ['draft', 'published', 'archived']);

decimal('price', 8, 2) — 8 total digits, 2 after decimal point. Perfect for prices.

boolean('is_active') — stores true/false as 1/0 in MySQL.

softDeletes() — adds a deleted_at column. Instead of actually deleting rows, Laravel marks them with a timestamp. The rows stay in the database but are hidden from normal queries. Essential for applications where you need to recover deleted data.

json('metadata') — stores JSON data directly in a column. MySQL parses it natively.

enum('status', [...]) — restricts the column to only the listed values.


Running, Rolling Back, and Refreshing Migrations

Run all pending migrations:

php artisan migrate

Rollback the last batch of migrations:

php artisan migrate:rollback

This calls the down() method of the last batch of migrations — undoing the last migrate operation.

Rollback a specific number of steps:

php artisan migrate:rollback --step=2

Drop everything and start fresh:

php artisan migrate:fresh

This drops all tables and reruns every migration from scratch. Use this during development when you want a clean slate. Never run it on a production database — all data will be lost.

Check migration status:

php artisan migrate:status

Shows every migration file and whether it has been run or not.


A Complete Example — Blog Routes and Migration Together

Let us put everything together. Update routes/web.php with a set of blog routes:


    <?php

    use Illuminate\Support\Facades\Route;

    Route::get('/', function () {
        return "Welcome to the Blog";
    })->name('home');

    Route::prefix('posts')->name('posts.')->group(function () {

        Route::get('/', function () {
            return "All Posts";
        })->name('index');

        Route::get('/create', function () {
            return "Create New Post Form";
        })->name('create');

        Route::post('/', function () {
            return "Store new post in database";
        })->name('store');

        Route::get('/{id}', function ($id) {
            return "Showing post number: " . $id;
        })->where('id', '[0-9]+')->name('show');

        Route::get('/{id}/edit', function ($id) {
            return "Edit post number: " . $id;
        })->where('id', '[0-9]+')->name('edit');

        Route::patch('/{id}', function ($id) {
            return "Update post number: " . $id;
        })->where('id', '[0-9]+')->name('update');

        Route::delete('/{id}', function ($id) {
            return "Delete post number: " . $id;
        })->where('id', '[0-9]+')->name('destroy');
    });

Now run:

php artisan route:list

You will see all your blog routes listed cleanly — their HTTP methods, URIs, names, and actions. This set of seven routes — index, create, store, show, edit, update, destroy — is the standard RESTful resource pattern. It is so common that Laravel can generate all seven with a single line. We will cover that in the next episode when we introduce controllers.

Now run your posts migration:

php artisan migrate

Check phpMyAdmin at http://localhost:8080/phpmyadmin — open laravel_myapp database. You will see the posts table with every column exactly as you defined it in the migration.


What Did We Learn in This Post?

Routes map URLs to actions. Route::get(), Route::post(), Route::put(), Route::patch(), and Route::delete() handle different HTTP methods.

Route parameters capture dynamic URL segments using {parameter} syntax. Optional parameters use {parameter?}. Constraints with where() restrict what values are accepted.

Named routes with ->name() let you reference URLs by name instead of hardcoding them — essential for maintainable applications.

Route groups with prefix() and name() organize related routes cleanly without repeating configuration.

php artisan route:list shows every registered route — your best debugging tool for routing issues.

Migrations define database table structure in PHP code. up() creates or modifies. down() rolls back. Every column type has a dedicated Blueprint method.

php artisan migrate runs migrations. php artisan migrate:rollback undoes the last batch. php artisan migrate:fresh drops everything and starts clean.


What is Coming in Episode 17?

Right now our routes use closures — anonymous functions directly in the route definition. That is fine for simple cases but becomes unmanageable in real applications.

Episode 17 covers Controllers — dedicated PHP classes that handle request logic. We will move our route logic into controllers, create a full PostController with all seven RESTful methods, and introduce Laravel's resource routing shortcut that generates all seven routes in one line.

See you in the next one.


Next Episode: Controllers — Organizing Your Application Logic the Laravel Way

This is Episode 16 of the PHP and Laravel — Zero to Hero series.


php.ini — The Configuration File That Controls How PHP Behaves

When PHP starts up, before it runs a single line of your code, it reads a configuration file. This file tells PHP how to behave — how much m...