All posts

Keep Business Logic Out of Your Routes

Route closures are convenient until they become the place where business logic hides. Let routes map URLs to actions and nothing more.

Marijan Kopčić••3 min read
Keep Business Logic Out of Your Routes

Route closures are one of Laravel's most convenient features. A single line in routes/web.php and you have a working endpoint. That convenience is also a trap: a closure that starts as two lines has a habit of becoming twenty, and suddenly your routing file is where business logic lives.

A route that does the work

Route::post('/article', function (Request $request) {
    $article = new Article;
    $article->title = $request->title;
    $article->content = $request->content;
    $article->save();

    return redirect('/articles');
});

There is no validation, no authorization and no obvious place to add either. The logic cannot be reused, cannot be type-hinted with a Form Request in a natural way, and is invisible to anyone looking for it in app/. Six months later someone searches the controllers for where articles are created and finds nothing.

Let the route only route

Route::post('/article', [ArticleController::class, 'store'])->name('articles.store');

class ArticleController extends Controller
{
    public function store(StoreArticleRequest $request): RedirectResponse
    {
        Article::create($request->validated());

        return redirect()->route('articles.index');
    }
}

The route file now does exactly one thing: it maps a URL and an HTTP verb to an action, and gives it a name. Validation lives in StoreArticleRequest, the action lives in a controller, and the rest of the application can refer to the endpoint by its name instead of a hardcoded path.

Why this pays off

The routes file stays a map. When routes/web.php contains only route definitions, you can read the whole surface of the application in one screen. Logic inside it turns the map into a novel.

Everything Laravel offers for controllers just works. Form Requests, dependency injection, middleware per action, policies through authorize(), resource controllers. A closure can use some of these, but awkwardly.

Findability. Business logic in app/Http/Controllers is where every Laravel developer looks first. Logic in a routes file is where nobody looks until something breaks.

Testability. A controller action is easy to reach from a feature test and easy to read in a stack trace. An anonymous closure shows up as {closure}, which tells you nothing.

Where a closure is perfectly fine

Not every route needs a controller. Laravel itself ships helpers for the cases where a controller would be pure ceremony:

Route::view('/about', 'about');
Route::redirect('/home', '/dashboard');

A closure that returns a static view or a simple redirect, with no input and no data access, is fine. The moment it reads the request, touches the database or calls a service, it has become an action, and actions belong in controllers.

A useful rule of thumb:

  • Static page or redirect → Route::view() or Route::redirect().
  • Reads input or touches data → a controller action.
  • More than one action on the same resource → a resource controller.

The practical test

Open routes/web.php and routes/api.php and search for function (. Every match that is longer than a single return statement is a controller action waiting to be extracted. Moving it takes minutes, and the next person looking for that logic will find it where they expect.

Keep reading