Controllers & Routing

Define routes with PHP attributes and create RESTful controllers

Creating a Controller

Controllers in Larafony extend the Controller base class and use the #[Route] attribute to define routes directly on methods.

<?php

declare(strict_types=1);

namespace App\Controllers;

use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class HomeController extends Controller
{
    #[Route('/', 'GET')]
    public function index(ServerRequestInterface $request): ResponseInterface
    {
        return $this->render('home', [
            'title' => 'Welcome to Larafony'
        ]);
    }
}
Auto-Discovery: Routes are automatically discovered from the src/Controllers directory. No need to manually register routes!

Route Attributes

The #[Route] attribute accepts a path and HTTP method:

#[Route('/notes', 'GET')]
public function index(): ResponseInterface
{
    // GET /notes
}

#[Route('/notes', 'POST')]
public function store(): ResponseInterface
{
    // POST /notes
}

#[Route('/notes/{id}', 'GET')]
public function show(int $id): ResponseInterface
{
    // GET /notes/123
}

#[Route('/notes/{id}', 'PUT')]
public function update(int $id): ResponseInterface
{
    // PUT /notes/123
}

#[Route('/notes/{id}', 'DELETE')]
public function destroy(int $id): ResponseInterface
{
    // DELETE /notes/123
}

Route Parameters

Capture route parameters using curly braces in the path:

#[Route('/users/{id}', 'GET')]
public function show(int $id): ResponseInterface
{
    $user = User::query()->find($id);

    return $this->render('users.show', ['user' => $user]);
}

#[Route('/posts/{slug}', 'GET')]
public function showBySlug(string $slug): ResponseInterface
{
    $post = Post::query()
        ->where('slug', '=', $slug)
        ->first();

    return $this->render('posts.show', ['post' => $post]);
}

Model Binding (Auto-Binding)

One of Larafony's most powerful features is automatic model binding. Using the #[RouteParam] attribute, you can automatically resolve route parameters into model instances. The framework will fetch the model from the database and inject it directly into your controller method.

Basic Model Binding

Use #[RouteParam] to configure model binding:

use App\Models\Note;
use Larafony\Framework\Routing\Advanced\Attributes\{Route, RouteParam};

#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
    // $note is automatically loaded from database
    // using the route parameter value

    return $this->render('notes.show', ['note' => $note]);
}
How it works: When you visit /notes/123, Larafony:
  1. Validates the parameter matches the pattern (\d+)
  2. Calls Note::findForRoute(123) to load the model
  3. Injects the loaded model into your controller method
  4. Returns 404 automatically if the model is not found!

Model findForRoute Method

Your model must implement the findForRoute() method for binding to work:

use Larafony\Framework\Database\ORM\Model;

class Note extends Model
{
    public string $table { get => 'notes'; }

    public static function findForRoute(int|string $id): ?static
    {
        return static::query()->find($id);
    }
}

Before and After Comparison

See how model binding simplifies your code:

// ❌ Without Model Binding (manual approach)
#[Route('/notes/<id:\d+>', 'GET')]
public function show(ServerRequestInterface $request): ResponseInterface
{
    $params = $request->getAttribute('routeParams');
    $note = Note::query()->find($params['id']);

    if (!$note) {
        // Handle 404
        return new Response(404, [], 'Note not found');
    }

    return $this->render('notes.show', ['note' => $note]);
}

// ✅ With Model Binding (automatic approach)
#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
    // $note is already loaded, 404 handled automatically!

    return $this->render('notes.show', ['note' => $note]);
}

Custom Resolution Methods

Use findMethod parameter to specify a custom resolution method (e.g., find by slug instead of ID):

// In your model
class Post extends Model
{
    public static function findBySlug(string $slug): ?static
    {
        return static::query()->where('slug', '=', $slug)->first();
    }
}

// In your controller
#[Route('/posts/<slug:[a-z0-9-]+>', 'GET')]
#[RouteParam(name: 'slug', bind: Post::class, findMethod: 'findBySlug')]
public function show(ServerRequestInterface $request, Post $post): ResponseInterface
{
    // $post resolved via findBySlug() instead of default findForRoute()
    // Pattern validation ([a-z0-9-]+) and binding in the same attribute!

    return $this->render('posts.show', ['post' => $post]);
}

Multiple Model Bindings

You can bind multiple models in a single route:

use App\Models\User;
use App\Models\Note;

#[Route('/users/<user>/notes/<note>', 'GET')]
#[RouteParam(name: 'user', pattern: '\d+', bind: User::class)]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function showUserNote(
    ServerRequestInterface $request,
    User $user,
    Note $note
): ResponseInterface {
    // Both models are automatically loaded!
    // $user is loaded from <user> parameter
    // $note is loaded from <note> parameter

    return $this->render('users.notes.show', [
        'user' => $user,
        'note' => $note
    ]);
}

Combining Model Binding with DTOs

Mix model binding with DTO injection for powerful update operations:

use App\Models\Note;
use App\DTOs\UpdateNoteDto;

#[Route('/notes/<note>', 'PUT')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function update(
    ServerRequestInterface $request,
    Note $note,
    UpdateNoteDto $dto
): ResponseInterface {
    // $note is auto-bound from route parameter
    // $dto is auto-created and validated from request body

    $note->title = $dto->title;
    $note->content = $dto->content;
    $note->save();

    return $this->redirect("/notes/{$note->id}");
}

Working with Relationships

Auto-bound models work seamlessly with relationships:

#[Route('/notes/<note>', 'GET')]
#[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
public function show(ServerRequestInterface $request, Note $note): ResponseInterface
{
    // Access relationships directly
    $author = $note->user;
    $tags = $note->tags;
    $comments = $note->comments;

    return $this->render('notes.show', [
        'note' => $note,
        'author' => $author,
        'tags' => $tags,
        'comments' => $comments
    ]);
}

Complete CRUD with Model Binding

Here's a complete CRUD controller using model binding:

use App\Models\Note;
use App\DTOs\{CreateNoteDto, UpdateNoteDto};
use Larafony\Framework\Routing\Advanced\Attributes\{Route, RouteParam};

class NoteController extends Controller
{
    // List all notes (no binding needed)
    #[Route('/notes', 'GET')]
    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $notes = Note::query()->get();
        return $this->render('notes.index', ['notes' => $notes]);
    }

    // Show single note (model binding)
    #[Route('/notes/<note>', 'GET')]
    #[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
    public function show(ServerRequestInterface $request, Note $note): ResponseInterface
    {
        return $this->render('notes.show', ['note' => $note]);
    }

    // Show edit form (model binding)
    #[Route('/notes/<note>/edit', 'GET')]
    #[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
    public function edit(ServerRequestInterface $request, Note $note): ResponseInterface
    {
        return $this->render('notes.edit', ['note' => $note]);
    }

    // Update note (model binding + DTO)
    #[Route('/notes/<note>', 'PUT')]
    #[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
    public function update(
        ServerRequestInterface $request,
        Note $note,
        UpdateNoteDto $dto
    ): ResponseInterface {
        $note->title = $dto->title;
        $note->content = $dto->content;
        $note->save();

        return $this->redirect("/notes/{$note->id}");
    }

    // Delete note (model binding)
    #[Route('/notes/<note>', 'DELETE')]
    #[RouteParam(name: 'note', pattern: '\d+', bind: Note::class)]
    public function destroy(ServerRequestInterface $request, Note $note): ResponseInterface
    {
        $note->delete();
        return $this->redirect('/notes');
    }
}
Route Parameter Syntax: Larafony uses <param> or <param:pattern> syntax for route parameters, not {param}. This allows inline regex patterns like <id:\d+> or <slug:[a-z0-9-]+>.

DTO Injection

Type-hint a DTO class to automatically validate and hydrate request data:

use App\DTOs\CreateNoteDto;

#[Route('/notes', 'POST')]
public function store(CreateNoteDto $dto): ResponseInterface
{
    // $dto is automatically created from request
    // and validated based on attributes

    $note = new Note()->fill([
        'title' => $dto->title,
        'content' => $dto->content,
    ]);
    $note->save();

    return $this->redirect('/notes');
}

See the DTO Validation guide for more details.

Response Helpers

The Controller base class provides helpful methods for creating responses:

Rendering Views

// Render a Blade template
return $this->render('notes.index', [
    'notes' => $notes
]);

JSON Responses

// Return JSON
return $this->json([
    'success' => true,
    'data' => $notes
]);

// With status code
return $this->json(['error' => 'Not found'], 404);

Redirects

// Redirect to a URL
return $this->redirect('/notes');

// Redirect with status code
return $this->redirect('/login', 302);

Complete CRUD Example

Here's a complete RESTful controller for managing notes:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Note;
use App\DTOs\CreateNoteDto;
use App\DTOs\UpdateNoteDto;
use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;

class NoteController extends Controller
{
    #[Route('/notes', 'GET')]
    public function index(): ResponseInterface
    {
        $notes = Note::query()->get();

        return $this->render('notes.index', ['notes' => $notes]);
    }

    #[Route('/notes/create', 'GET')]
    public function create(): ResponseInterface
    {
        return $this->render('notes.create');
    }

    #[Route('/notes', 'POST')]
    public function store(CreateNoteDto $dto): ResponseInterface
    {
        $note = new Note()->fill([
            'title' => $dto->title,
            'content' => $dto->content,
            'user_id' => 1, // Get from auth
        ]);
        $note->save();

        return $this->redirect('/notes');
    }

    #[Route('/notes/{id}', 'GET')]
    public function show(Note $note): ResponseInterface
    {
        return $this->render('notes.show', ['note' => $note]);
    }

    #[Route('/notes/{id}/edit', 'GET')]
    public function edit(Note $note): ResponseInterface
    {
        return $this->render('notes.edit', ['note' => $note]);
    }

    #[Route('/notes/{id}', 'PUT')]
    public function update(Note $note, UpdateNoteDto $dto): ResponseInterface
    {
        $note->title = $dto->title;
        $note->content = $dto->content;
        $note->save();

        return $this->redirect("/notes/{$note->id}");
    }

    #[Route('/notes/{id}', 'DELETE')]
    public function destroy(Note $note): ResponseInterface
    {
        $note->delete();

        return $this->redirect('/notes');
    }
}

API Controllers

Create JSON APIs by returning JSON responses:

<?php

namespace App\Controllers;

use App\Models\Note;
use Larafony\Framework\Routing\Advanced\Attributes\Route;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;

class ApiNoteController extends Controller
{
    #[Route('/api/notes', 'GET')]
    public function index(): ResponseInterface
    {
        $notes = Note::query()->get();

        return $this->json([
            'success' => true,
            'data' => $notes
        ]);
    }

    #[Route('/api/notes/{id}', 'GET')]
    public function show(Note $note): ResponseInterface
    {
        return $this->json([
            'success' => true,
            'data' => $note
        ]);
    }
}

Next Steps