Closures in PHP — Anonymous Functions Explained Completely

If you've written routes in Laravel, you've already used closures without realizing it. That function () { ... } sitting inside Route::get() — that's a closure. But closures go far deeper than routes. They're one of PHP's most powerful and flexible features, used everywhere from collections to callbacks to event handling.


Start From the Beginning — What is a Function

Before understanding closures, lock in the basics.

A normal function in PHP has a name. You define it once, call it anywhere by that name:


    <?php

    function add(int $a, int $b): int
    {
        return $a + $b;
    }

    echo add(3, 4);

Output:

7

Simple. The function lives at the global level, has an identity (add), and can be called from anywhere in your code.


A Closure is a Function Without a Name

A closure is the same concept — a block of code that takes inputs and returns output — but it has no name. It's anonymous:


    <?php

    function (int $a, int $b): int {
        return $a + $b;
    };

But wait — if it has no name, how do you use it? You assign it to a variable:


    <?php

    $add = function (int $a, int $b): int {
        return $a + $b;
    };

    echo $add(3, 4);

Output:

7

The closure lives inside $add. You call it like a function using that variable. The variable is just a container holding the anonymous function.


The Three Ways to Write Closures in PHP

PHP has three distinct syntaxes for closures, each with its own use case.

1. Classic Anonymous Function

The original closure syntax, available since PHP 5.3:


    <?php

    $greet = function (string $name): string {
        return "Hello, " . $name . "!";
    };

    echo $greet("Gagan");

Hello, Gagan!

2. Arrow Function — fn

Introduced in PHP 7.4. Cleaner, single-expression syntax:


    <?php

    $greet = fn(string $name): string => "Hello, " . $name . "!";

    echo $greet("Gagan");

Same output. Arrow functions are perfect for short, single-line operations. They can't span multiple lines.

3. Static Closure

A closure that cannot access $this — used inside classes when you want an anonymous function that doesn't bind to the object:


    <?php

    $multiply = static function (int $a, int $b): int {
        return $a * $b;
    };

    echo $multiply(4, 5);

    // Output: 20


The Key Feature — Closing Over Variables

This is what makes a closure a closure — it can capture variables from its surrounding scope.

Normal named functions are isolated. They cannot see variables defined outside them:


    <?php

    $discount = 10;

    function calculatePrice(int $price): int
    {
        return $price - $discount;
    }

This throws an error — $discount is not visible inside the named function.

A closure solves this with the use keyword:


    <?php

    $discount = 10;

    $calculatePrice = function (int $price) use ($discount): int {
        return $price - $discount;
    };

    echo $calculatePrice(100);

90

The closure "closed over" $discount from the outer scope — captured it, made it available inside. This is literally where the name "closure" comes from.

Capture by Value vs Capture by Reference

By default, use captures the value at the time the closure is defined — not when it's called:


    <?php

    $discount = 10;

    $calculatePrice = function (int $price) use ($discount): int {
        return $price - $discount;
    };

    $discount = 50;

    echo $calculatePrice(100);

90

$discount was 10 when the closure was defined. Changing it later has no effect inside the closure.

To capture by reference — so changes reflect inside the closure — use &:


    <?php

    $discount = 10;

    $calculatePrice = function (int $price) use (&$discount): int {
        return $price - $discount;
    };

    $discount = 50;

    echo $calculatePrice(100);

50

Now the closure sees the live value of $discount.

Arrow Functions Capture Automatically

Arrow functions (fn) don't need use at all — they automatically capture all variables from the outer scope by value:


    <?php

    $discount = 10;

    $calculatePrice = fn(int $price): int => $price - $discount;

    echo $calculatePrice(100);

90

No use needed. This is one of the main reasons arrow functions are preferred for short operations.


Closures as Arguments — The Real Power

The most common and powerful use of closures is passing them as arguments to other functions. This is called a callback.

With Array Functions

PHP's built-in array functions accept closures as callbacks:


    <?php

    $numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    $evens = array_filter($numbers, fn(int $n): bool => $n % 2 === 0);

    $doubled = array_map(fn(int $n): int => $n * 2, $numbers);

    $sum = array_reduce($numbers, fn(int $carry, int $n): int => $carry + $n, 0);

    print_r($evens);
    print_r($doubled);
    echo $sum;

[2, 4, 6, 8, 10]
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
55

Each closure runs once per array element. You define the logic inline — no need to create a named function just to use it once.

With usort

Sorting with custom logic:


    <?php

    $users = [
        ['name' => 'Ravi', 'age' => 30],
        ['name' => 'Gagan', 'age' => 25],
        ['name' => 'Amit', 'age' => 28],
    ];

    usort($users, fn(array $a, array $b): int => $a['age'] <=> $b['age']);

    foreach ($users as $user) {
        echo $user['name'] . '' . $user['age'] . PHP_EOL;
    }

Gagan — 25
Amit — 28
Ravi — 30

Closures in Laravel — Where You've Already Seen Them

In Routes


    <?php

    Route::get('/posts', function () {
        return "Showing all posts";
    });

That function () { ... } is a closure passed as the second argument to Route::get(). Laravel stores it and calls it when someone visits /posts.

In Collections

Laravel's Collection class is built around closures:


    <?php

    $posts = collect([
        ['title' => 'PHP Basics', 'published' => true],
        ['title' => 'Laravel Setup', 'published' => false],
        ['title' => 'Eloquent ORM', 'published' => true],
    ]);

    $published = $posts
        ->filter(fn(array $post): bool => $post['published'])
        ->map(fn(array $post): string => strtoupper($post['title']))
        ->values();

    print_r($published->toArray());

['PHP BASICS', 'ELOQUENT ORM']

Every filter(), map(), reject(), sortBy(), each() call in Laravel Collections takes a closure. The entire fluent chaining system is built on closures being passed and executed per item.

In Middleware


    <?php

    Route::get('/dashboard', function () {
        return view('dashboard');
    })->middleware(function ($request, $next) {
        if (!auth()->check()) {
            return redirect('/login');
        }
        return $next($request);
    });

The middleware itself can be a closure — a function that receives the request, does something, and passes it forward.

In Event Listeners


    <?php

    Event::listen('user.registered', function (User $user): void {
        Mail::to($user->email)->send(new WelcomeMail($user));
    });


Returning a Closure From a Function

Closures can also be returned from functions — this creates what's called a higher-order function:


    <?php

    function multiplier(int $factor): Closure
    {
        return fn(int $number): int => $number * $factor;
    }

    $double = multiplier(2);
    $triple = multiplier(3);
    $tenX = multiplier(10);

    echo $double(5);
    echo $triple(5);
    echo $tenX(5);

10
15
50

multiplier() returns a closure, not a value. Each call to multiplier() creates a new closure that has captured a different $factor. You now have three separate functions built from one factory function.

This pattern is used heavily in middleware pipelines, validation rule builders, and query scopes.


Closures Inside Classes — Binding $this

When you create a closure inside a class, it can access the object's properties via $this automatically:


    <?php

    class Cart
    {
        private float $taxRate = 0.18;
        private array $items = [];

        public function addItem(string $name, float $price): void
        {
            $this->items[] = ['name' => $name, 'price' => $price];
        }

        public function getTotal(): float
        {
            $taxRate = $this->taxRate;

            $total = array_reduce(
                $this->items,
                fn(float $carry, array $item): float => $carry + $item['price'],
                0.0
            );

            return $total + ($total * $taxRate);
        }
    }

    $cart = new Cart();
    $cart->addItem('Laptop', 999.00);
    $cart->addItem('Mouse', 29.00);

    echo $cart->getTotal();

1213.04

Closure vs Named Function — When to Use Which

Situation

Use

Used once, short logic

Closure / Arrow function

Used in multiple places

Named function

Passed as callback to array functions

Arrow function

Complex multi-line logic

Named function or method

Route handler in small apps

Closure

Route handler in real applications

Controller method

Laravel Collection operations

Arrow function

Event listeners and hooks

Closure



The Complete Mental Model

Named Function     → Has a name, global scope, called by name
Closure            → No name, assigned to variable or passed directly
Arrow Function     → Closure shorthand, auto-captures outer variables
Higher-Order Fn    → A function that takes or returns another function

Every time you see function () { } or fn() => in PHP — that is a closure. Whether it's inside a route, a collection chain, an array function, or an event listener — same concept, same rules, different context.


The Bottom Line

A closure is simply a function that exists without a name. What makes it powerful is the combination of three things — it can be stored in a variable, passed as an argument to another function, and capture variables from the scope where it was defined. These three abilities together enable an entirely different style of programming in PHP — one that Laravel's entire collection system, routing layer, and event system is built on top of. Once closures click, a huge portion of Laravel's design suddenly becomes obvious.

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 17: Controllers — Organizing Your Application Logic the Laravel Way

What Are We Doing in This Post? In Episode 16 we defined routes using closures — anonymous functions directly inside routes/web.php . That w...