📌 Dynamic routing in PHP senza framework: guida moderna

Una guida pratica al dynamic routing in PHP senza framework, con approccio moderno basato su front controller, URI normalizzati, parametri dinamici, metodi HTTP e gestione corretta degli errori 404 e 405.

Nodo digitale luminoso al centro di una rete di percorsi che collega diverse finestre di un’applicazione web.

Autore: alessiopuppi · Creato: 07/03/2026 19:09

Capire davvero il routing: architettura, dispatch e responsabilità

Quando un sito web cresce, il problema non è solo mostrare pagine: il vero problema è decidere in modo pulito quale logica eseguire quando arriva una richiesta. È qui che entra in gioco il routing.

Questa guida è pensata per chi vuole capire come funziona davvero il routing in PHP, senza nascondere la logica dietro un framework. Non è un esercizio nostalgico: è un modo concreto per comprendere front controller, dispatch, matching delle rotte e separazione delle responsabilità in un’applicazione web reale.

Molti sviluppatori incontrano il routing dentro framework come Laravel o Symfony, ma impararlo soltanto attraverso un framework spesso significa usarlo senza comprenderne davvero la struttura. Costruire invece un sistema di dynamic routing in PHP senza framework è uno degli esercizi più utili per capire come funziona davvero un’applicazione web.

Non si tratta di rifiutare i framework per principio. Si tratta di capire l’architettura di base: come un URL viene letto, interpretato, confrontato con una mappa di rotte e infine collegato al codice giusto. Quando questa logica è chiara, anche l’uso di un framework diventa più consapevole. Quando non è chiara, si lavora per imitazione, e l’imitazione in programmazione genera quasi sempre codice fragile.

La struttura di base di una richiesta web

Prima di entrare nel dettaglio del router, è utile visualizzare il flusso reale di una richiesta HTTP dentro un'applicazione web.

Il percorso logico è il seguente:

Richiesta HTTP
      ↓
Web Server (Apache / Nginx)
      ↓
Front Controller (index.php)
      ↓
Router
      ↓
Matching della rotta
      ↓
Controller
      ↓
View o risposta HTTP

Il router occupa quindi una posizione centrale: riceve il path richiesto e decide quale parte dell’applicazione deve occuparsene. Tutto ciò che avviene dopo dipende da questa decisione.

Che cos’è davvero il routing

Il routing è il meccanismo che riceve una richiesta HTTP, analizza l’URL richiesto e decide quale parte dell’applicazione deve rispondere. In termini semplici, è il punto in cui il sito riconosce dove l’utente vuole andare e traduce quella richiesta in un’azione concreta.

Un esempio minimale può essere rappresentato così:

$routes = [
    '/' => 'HomeController@index',
    '/contatti' => 'ContactController@index',
    '/articoli' => 'ArticleController@index',
];

Questo approccio però è solo l’inizio. Va bene per capire il principio, ma resta troppo rigido. Un progetto reale deve fare di più: distinguere i metodi HTTP, gestire parametri dinamici e rispondere con codici corretti in caso di errore.

Perché i tutorial troppo semplici invecchiano male

Il classico esempio da blog legge l’URI, confronta il path con un array di stringhe e richiama un controller. Sulla carta funziona. Nella realtà, appena l’applicazione cresce, iniziano i problemi.

Per esempio, un router troppo semplice non distingue tra GET e POST:

// approccio troppo minimale
if ($uri === '/login') {
    require 'controllers/LoginController.php';
}

Questa logica ignora un fatto fondamentale: una richiesta GET verso /login e una POST verso /login non rappresentano la stessa operazione. La prima in genere mostra il modulo, la seconda elabora i dati. Se il router non conosce questa differenza, scarica il problema altrove e l’architettura si sporca.

Il punto corretto di ingresso: il front controller

Un’impostazione moderna parte da un unico punto di ingresso, di solito index.php. Questo file non dovrebbe contenere tutta la logica dell’applicazione. Dovrebbe solo inizializzare il sistema, caricare le rotte e passare il controllo al router.

Un bootstrap essenziale può essere qualcosa di questo tipo:

<?php
declare(strict_types=1);

use App\Core\Router;

spl_autoload_register(function (string $class): void {
    $prefix = 'App\\\\';
    $baseDir = __DIR__ . '/src/';

    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relativeClass = substr($class, strlen($prefix));
    $file = $baseDir . str_replace('\\\\', '/', $relativeClass) . '.php';

    if (is_file($file)) {
        require $file;
    }
});

$router = new Router();

require __DIR__ . '/routes/web.php';

$router->dispatch(
    $_SERVER['REQUEST_METHOD'] ?? 'GET',
    $_SERVER['REQUEST_URI'] ?? '/'
);

Qui il principio è pulito: tutte le richieste entrano da un solo punto, il router viene inizializzato, le rotte vengono registrate e il dispatch decide quale handler eseguire. Questo è già un salto netto rispetto ai vecchi sistemi basati su include sparsi e condizioni ripetute.

Normalizzare l’URI è indispensabile

Uno degli aspetti più sottovalutati nella scrittura di un router artigianale è la normalizzazione dell’URI. Leggere direttamente $_SERVER['REQUEST_URI'] e confrontarlo alla cieca con una stringa è una scorciatoia rozza. Prima bisogna separare il path dalla query string, ripulire gli slash inutili e rendere coerente il formato del percorso.

Un frammento tipico è questo:

$path = parse_url($uri, PHP_URL_PATH) ?? '/';
$path = rawurldecode($path);
$path = '/' . trim($path, '/');
$path = $path === '//' ? '/' : $path;

Senza questo passaggio, un router si comporta in modo instabile su URL che dovrebbero essere equivalenti. È il genere di dettaglio che i tutorial superficiali saltano, ma è proprio lì che si vede se il codice è un giocattolo o una base credibile.

Rotte statiche e rotte dinamiche

In un sito vero non esistono soltanto pagine statiche. Esistono articoli, profili, prodotti, categorie, archivi. Questo significa che il router deve poter gestire pattern dinamici, non solo stringhe fisse.

Per esempio:

$router->get('/', [HomeController::class, 'index']);
$router->get('/articoli/{slug}', [ArticleController::class, 'show']);
$router->post('/login', [AuthController::class, 'login']);

Qui il percorso /articoli/{slug} non rappresenta una singola pagina, ma una famiglia di URL. Il router deve riconoscere la parte variabile, estrarla e passarla al controller corretto. Questo è il passaggio che trasforma un routing rigido in un routing davvero utile.

Middleware: lo strato intermedio tra router e controller

Nei sistemi più evoluti, il router non si limita a inoltrare la richiesta al controller. Può anche eseguire uno o più middleware prima di passare il controllo all’handler finale.

Un middleware è una funzione che intercetta la richiesta e può eseguire operazioni preliminari, per esempio:

  • verificare se l’utente è autenticato
  • registrare log di accesso
  • applicare rate limiting
  • modificare o validare la richiesta

Il concetto è semplice: il router stabilisce dove andare, mentre i middleware controllano se e come la richiesta può proseguire.

Anche nei framework moderni — come Laravel o Express — questo meccanismo è centrale nell’architettura dell’applicazione.

Come funziona internamente il matching

Per gestire parametri dinamici, una soluzione pulita consiste nel convertire i pattern delle rotte in espressioni regolari. È un approccio semplice ma già sufficientemente potente per molti progetti custom.

Un esempio di trasformazione del pattern può essere questo:

$regex = preg_replace_callback(
    '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
    static fn(array $matches): string => '(?P<' . $matches[1] . '>[^/]+)',
    $pattern
);

$regex = '#^' . $regex . '$#';

Così una rotta come /articoli/{slug} diventa una regex capace di catturare il valore dello slug con un nome associato. Il vantaggio è evidente: il controller non riceve un blocco di testo grezzo, ma parametri già separati in modo leggibile.

Un router piccolo ma serio

Il cuore del sistema sta nella fase di dispatch. Qui il router scorre le rotte registrate, verifica quale pattern corrisponde al path richiesto, controlla il metodo HTTP e infine esegue l’handler giusto.

Una logica essenziale può essere riassunta così:

foreach ($this->routes as $route) {
    $match = preg_match($route['regex'], $path, $matches);

    if ($match !== 1) {
        continue;
    }

    $allowedMethods[] = $route['method'];

    if ($route['method'] !== $method) {
        continue;
    }

    $params = array_filter(
        $matches,
        static fn($key): bool => is_string($key),
        ARRAY_FILTER_USE_KEY
    );

    $this->runHandler($route['handler'], $params);
    return;
}

if (!empty($allowedMethods)) {
    http_response_code(405);
    header('Allow: ' . implode(', ', array_unique($allowedMethods)));
    echo '405 Method Not Allowed';
    return;
}

http_response_code(404);
echo '404 Page Not Found';

Il punto importante è che qui il router non si limita a “trovare qualcosa che somiglia”. Fa tre operazioni distinte: verifica la corrispondenza del pattern, controlla il metodo e infine passa i parametri estratti all’handler. È una struttura piccola, ma già architettonicamente sana.

404 e 405 non sono la stessa cosa

Questo dettaglio merita attenzione. Una richiesta verso una rotta inesistente deve produrre un 404 Not Found. Una richiesta verso una rotta esistente ma con metodo non ammesso deve produrre un 405 Method Not Allowed. Confondere i due casi è un errore tipico del codice immaturo.

La differenza non è solo formale. Un’applicazione che risponde con codici HTTP corretti è più leggibile, più professionale e più facile da debuggare. I sistemi scritti bene non solo funzionano: comunicano con precisione.

Il controller deve ricevere il controllo, non fare il lavoro del router

Una volta trovata la rotta corretta, il router passa il controllo al controller. È lì che entra in gioco la logica applicativa. Per esempio, un controller per un articolo può ricevere lo slug già estratto:

<?php
declare(strict_types=1);

namespace App\Controllers;

final class ArticleController
{
    public function show(array $params): void
    {
        $slug = $params['slug'] ?? '';

        if ($slug === '') {
            http_response_code(400);
            echo 'Slug non valido.';
            return;
        }

        $article = [
            'title' => 'Esempio articolo',
            'body' => 'Contenuto caricato dinamicamente per lo slug: ' . $slug,
        ];

        require __DIR__ . '/../../views/article.php';
    }
}

Qui il controller non deve reinterpretare l’URL da zero. Quel lavoro spetta già al router. Quando le responsabilità restano separate, il progetto rimane leggibile. Quando invece router e controller iniziano a invadere i rispettivi territori, il codice si trasforma in una massa vischiosa di eccezioni e rattoppi.

Il rewrite delle richieste

Per far funzionare davvero il sistema, bisogna fare in modo che le richieste vengano inoltrate a index.php, tranne quando il file o la directory esistono davvero. In ambiente Apache questo si ottiene con un file .htaccess molto semplice:

RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

Senza questo passaggio il router resta monco, perché il server cercherà file fisici invece di lasciare al front controller il compito di interpretare la richiesta.

Il valore reale di un router custom

Scrivere un proprio router non serve a dimostrare superiorità rispetto ai framework. Serve a comprendere il meccanismo. E comprendere il meccanismo cambia il modo in cui si scrive codice.

Chi capisce davvero il routing non vede più un URL come una stringa da confrontare brutalmente. Lo vede come una struttura di ingresso. Non vede più index.php come un file da riempire, ma come una soglia architetturale. Non vede più i controller come contenitori casuali, ma come punti di azione chiaramente separati.

In questo senso, il dynamic routing in PHP senza framework è un esercizio di disciplina tecnica. Costringe a pensare per responsabilità, per flusso, per semantica e per manutenzione. E questa disciplina vale molto più del singolo snippet di codice.


Una possibile struttura del progetto

Un piccolo router come quello descritto in questo articolo può essere integrato in una struttura di progetto semplice ma ordinata:

project/
│
├── public/
│   └── index.php
│
├── src/
│   ├── Core/
│   │   └── Router.php
│   │
│   └── Controllers/
│       ├── HomeController.php
│       └── ArticleController.php
│
├── routes/
│   └── web.php
│
└── views/
    └── article.php

Separare chiaramente router, controller, viste e definizione delle rotte rende il sistema più leggibile e molto più facile da mantenere nel tempo.

Conclusione

Il dynamic routing in PHP senza framework non è un reperto nostalgico e non è una sfida ideologica. È una scelta progettuale che, se fatta bene, permette di capire a fondo come una richiesta entra nel sistema, come viene interpretata e come viene trasformata in una risposta coerente.

La lezione decisiva è semplice: non serve un framework per avere architettura, ma serve architettura anche quando non usi un framework. Un sito dinamico non diventa solido perché usa parole moderne o librerie famose. Diventa solido quando la sua struttura di ingresso è chiara, separata, estendibile e semanticamente corretta.

E il router, in tutto questo, non è un accessorio. È il punto in cui il sito smette di essere una pila di file e comincia a comportarsi come un sistema.

Con poche centinaia di righe è possibile costruire un router solido, leggibile ed estendibile. Capire questo meccanismo significa capire davvero come funziona un’applicazione web moderna, perché sotto la superficie la logica non cambia: una richiesta entra nel sistema, viene interpretata, associata a una rotta e infine delegata al componente corretto. In altre parole, comprendere il routing significa comprendere il punto in cui un insieme di file smette di essere semplice codice sparso e comincia a comportarsi come una vera architettura software.

Messaggio sponsorizzato
Pubblicità

🔗 Condividi l'articolo:

Autore: alessiopuppi · Creato: 07/03/2026 19:09 · Ultima modifica: 03/09/2026 12:17