Dal controller grasso alla classe che fa una cosa sola: come scegliere i nomi, definire la firma, comporre le operazioni e testare tutto senza framework
Ogni applicazione Laravel che cresce attraversa le stesse tre fasi. Si parte con la logica nei controller, perché è lì che è comodo scriverla. Quando i controller diventano illeggibili si estrae un service — OrdineService — e per qualche mese sembra la soluzione. Poi, un giorno, si apre quel file e si scopre che ha milleduecento righe, ventotto metodi pubblici, nove dipendenze nel costruttore e che nessuno sa più dire quali metodi vengano effettivamente usati.
Il god service non è un fallimento dell’estrazione: è la conseguenza di aver estratto nella direzione sbagliata. Raggruppare il codice per entità — tutto quello che riguarda gli ordini in una classe — produce classi che crescono senza limite, perché le cose che si possono fare con un ordine non finiscono mai. Raggruppare per operazione — una classe per ogni cosa che l’applicazione sa fare — produce classi che hanno una dimensione naturale e smettono di crescere.
Questa è l’idea dell’Action Pattern: una classe per ogni operazione di business, con un nome che è un verbo, un solo punto d’ingresso pubblico e tutto ciò che le serve dichiarato nel costruttore. ConfermaOrdine, AnnullaAbbonamento, GeneraFatturaDaOrdine, ImportaListinoFornitore. Niente di tutto questo è nuovo — è la forma più diretta del Single Responsibility Principle — ma la convenzione, applicata con disciplina, cambia molto il modo in cui un progetto invecchia.
Questo articolo percorre i passi per arrivarci, con un avvertimento utile da subito: l’Action Pattern è una delle astrazioni più facili da applicare in eccesso, e l’ultima sezione serve a riconoscere quando lo si sta facendo.
Che cosa dice davvero il Single Responsibility Principle
Vale la pena chiarirlo, perché il principio viene citato in una versione imprecisa che porta a conclusioni sbagliate. La formulazione popolare è “una classe deve fare una cosa sola”, e letta così è inutilizzabile: qualunque classe fa molte cose, dipende solo da quanto si zooma.
La formulazione originale di Robert Martin è diversa e più precisa: una classe deve avere una sola ragione per cambiare, cioè deve rispondere a un solo committente. Se la classe che calcola gli stipendi cambia quando lo decide l’ufficio paghe e quando lo decide l’ufficio contabilità, quelle sono due responsabilità e vanno separate — non perché siano “due cose”, ma perché hanno due padroni diversi che chiederanno modifiche in momenti diversi.
Applicato alle action, il criterio diventa operativo: un’action incapsula un’operazione di business che cambia per un solo motivo. “Conferma ordine” cambia quando cambia la regola di conferma. Se nello stesso metodo c’è anche la generazione del PDF di cortesia, quella cambierà quando il grafico rifarà il layout — motivo diverso, committente diverso, classe diversa.
Passo 1 — Riconoscere i sintomi nel codice esistente
Prima di introdurre la convenzione serve capire dove la logica si è accumulata. Cinque sintomi, tutti facili da cercare.
- Il metodo di controller che scorre. Un
store()di ottanta righe che valida, crea, calcola, invia email, scrive sul log e ritorna una redirect. Si riconosce perché per capirlo bisogna scorrere. - Il god service. Una classe
XServicecon più di una decina di metodi pubblici e un costruttore che inietta mezza applicazione. Le dipendenze sono l’unione di quelle di tutti i metodi: ogni chiamante ne costruisce nove per usarne due. - I trait condivisi per riusare logica.
use CalcolaTotali, InviaNotifichein cinque classi diverse. Il trait è riuso per copia, non per composizione: non si può sostituire nei test, non ha dipendenze dichiarate e nasconde da dove viene un metodo. - La logica di business negli eventi del modello. Un
booted()che susavedinvia email e chiama API. Significa che qualsiasisave(), in qualsiasi punto — anche in un seeder o in un test — scatena effetti collaterali invisibili. - Lo stesso flusso riscritto due volte. La conferma dell’ordine implementata una volta nel controller web e una seconda, leggermente diversa, nel comando Artisan che gira di notte. Le due versioni divergono entro tre mesi.
Un indicatore sintetico utile: quanti punti dell’applicazione sanno come si conferma un ordine? Se la risposta è più di uno, la prossima modifica alla regola verrà applicata a uno solo di essi.
Passo 2 — Nominare l’azione
Sembra il passo meno tecnico ed è quello che determina la qualità del risultato, perché il nome è la progettazione. Un’operazione che non si riesce a nominare con un verbo e un complemento oggetto non è un’operazione: è un insieme di cose messe insieme per comodità.
Le regole che funzionano sono quattro.
Verbo all’imperativo più oggetto, nel linguaggio del dominio. ConfermaOrdine, RiattivaAbbonamento, RiconciliaIncasso. Non OrdineHandler, non OrdineManager, non ProcessaOrdine — “processare” è un verbo che non significa niente e tipicamente nasconde tre operazioni distinte.
Il nome viene da chi usa l’applicazione, non da chi la scrive. Se in azienda si dice “chiudere il sinistro”, la classe si chiama ChiudiSinistro e non UpdateClaimStatusToClosed. Il beneficio pratico è che l’elenco delle classi nella cartella diventa l’elenco delle cose che il sistema sa fare, leggibile anche da chi non programma.
Se nel nome compare una “e”, sono due action. ConfermaOrdineEInviaRicevuta si spezza in due. È il test più rapido ed è sorprendentemente efficace.
Se il nome è generico, manca una decisione. GestisciPagamento non dice se registra un incasso, autorizza un addebito o riconcilia uno storno. Il nome vago è sempre il sintomo di un confine non ancora deciso, e rinviare quella decisione significa scrivere una classe che ne farà tre.
Il nome del metodo
Sulla convenzione del punto d’ingresso esistono tre scuole, e la scelta conta meno della coerenza.
| Forma | Chiamata | Nota |
|---|---|---|
__invoke() | ($this->conferma)($id) | La più compatta, ma la sintassi con le parentesi è poco leggibile e non si cerca con un grep |
handle() | $this->conferma->handle($id) | Coerente con job e listener di Laravel; il verbo però non dice nulla |
esegui() / execute() | $this->conferma->esegui($id) | Esplicito e cercabile. È quella che uso negli esempi |
Qualunque sia la scelta, va applicata a tutte le action del progetto: un codice in cui metà si invocano con handle() e metà con le parentesi costringe a controllare ogni volta.
Passo 3 — Definire la firma: input e output tipizzati
Un’action ben fatta si capisce dalla firma, prima di leggere il corpo. Due decisioni la determinano.
L’input: un DTO, non sette parametri
Finché i parametri sono uno o due, passarli direttamente va bene. Appena diventano tre o più — e appena alcuni sono opzionali — la firma va sostituita con un oggetto di input. Il motivo non è estetico: con sette parametri posizionali, chi chiama sbaglia l’ordine e il tipo non lo salva, perché tre di quei parametri sono stringhe.
namespace App\Ordini\Application;
/** L'input dell'operazione, validato e tipizzato una volta per tutte */
final readonly class DatiConfermaOrdine
{
public function __construct(
public OrdineId $ordine,
public UtenteId $confermatoDa,
public \DateTimeImmutable $quando,
public ?string $note = null,
public bool $forzaScortaInsufficiente = false,
) {}
/** Costruttore dedicato al livello HTTP: l'unico punto che conosce la request */
public static function daRequest(ConfermaOrdineRequest $request): self
{
return new self(
ordine: OrdineId::da($request->route('ordine')),
confermatoDa: UtenteId::da($request->user()->id),
quando: new \DateTimeImmutable(),
note: $request->input('note'),
forzaScortaInsufficiente: $request->boolean('forza'),
);
}
}Il metodo statico di costruzione è il dettaglio che tiene l’action pulita: la conoscenza di come arrivano i dati via HTTP sta nel DTO, non dentro l’operazione. Un comando Artisan costruirà lo stesso DTO a mano, e l’action non vedrà differenza.
L’output: l’oggetto prodotto, non una Response
Un’action restituisce il risultato dell’operazione — l’entità creata, un identificativo, un oggetto di esito — oppure void se non produce nulla di interessante. Non restituisce mai una RedirectResponse, un JsonResponse o una vista: nel momento in cui lo fa, diventa utilizzabile solo da HTTP e il riuso da comando o da coda è finito.
Per le operazioni che possono fallire in modi previsti dal dominio, l’alternativa all’eccezione è un oggetto di esito. La regola che uso: eccezione quando il chiamante non può fare nulla di sensato (l’ordine non esiste, lo stato non consente la conferma), oggetto di esito quando il fallimento parziale è un risultato normale — tipico delle importazioni.
final readonly class EsitoImportazione
{
public function __construct(
public int $righeLette,
public int $righeImportate,
/** @var list<ErroreRiga> */
public array $errori,
) {}
public function completa(): bool { return $this->errori === []; }
}Passo 4 — Scrivere l’action
Con nome e firma decisi, la classe si scrive quasi da sé. Questa è la forma completa, con i commenti che spiegano le scelte non ovvie.
namespace App\Ordini\Application;
use App\Ordini\Domain\{OrdineRepository, OrdineNonConfermabile};
use App\Magazzino\Application\ImpegnaScorta;
use App\Shared\Domain\{Transazioni, Eventi};
final readonly class ConfermaOrdine
{
/** Tutte le dipendenze dichiarate: nessuna facade, nessun app(), nessun auth() nel corpo */
public function __construct(
private OrdineRepository $ordini,
private ImpegnaScorta $impegnaScorta,
private Transazioni $transazioni,
private Eventi $eventi,
) {}
public function esegui(DatiConfermaOrdine $dati): Ordine
{
$ordine = $this->transazioni->esegui(function () use ($dati) {
$ordine = $this->ordini->perId($dati->ordine);
// La regola di business sta nell'entità: l'action coordina, non decide
$ordine->conferma($dati->quando, $dati->confermatoDa, $dati->note);
// Un'altra action, iniettata: nessuna duplicazione della logica di magazzino
$this->impegnaScorta->esegui(
new DatiImpegnoScorta($ordine->righe(), forza: $dati->forzaScortaInsufficiente)
);
$this->ordini->salva($ordine);
return $ordine;
});
// Gli effetti esterni stanno fuori dalla transazione
$this->eventi->pubblica(new OrdineConfermato($ordine->id(), $dati->quando));
return $ordine;
}
}Sessanta righe di controller diventano quindici righe che si leggono come una descrizione dell’operazione. Quattro proprietà di questa classe meritano di essere nominate, perché sono quelle che la rendono utile e non solo più corta.
Le dipendenze sono visibili nel costruttore. Leggendo la firma si sa esattamente cosa tocca questa operazione: il repository degli ordini, il magazzino, le transazioni, gli eventi. Un service con ventotto metodi non offre questa informazione per nessuno di essi.
Nessuna dipendenza nascosta. Dentro il corpo non compaiono auth(), request(), config() o now(). L’utente che conferma e il momento della conferma arrivano come dati: è ciò che rende l’action collaudabile senza simulare una richiesta HTTP e senza congelare l’orologio di sistema.
Non c’è autorizzazione. Il controllo su chi può confermare l’ordine sta al confine — nella policy invocata dal controller — non qui. Il motivo è che i confini hanno regole diverse: un comando notturno che conferma gli ordini pagati non ha un utente autenticato, e un’action con Gate::authorize() dentro diventa inservibile da lì.
Il corpo è una sequenza, non un albero. Se compaiono condizionali annidati o un switch su un tipo, è il segnale che dentro una action ce ne sono tre travestite.
Passo 5 — Collocare le action nel progetto
La struttura delle cartelle è una convenzione, non una regola: l’unica cosa che conta è che sia prevedibile. Due organizzazioni funzionano bene, e la scelta dipende dalla dimensione del progetto.
Per dominio, su progetti medi e grandi — è l’organizzazione che consiglio, perché un dominio si legge aprendo una cartella:
app/
Ordini/
Domain/ Ordine.php, OrdineRepository.php, StatoOrdine.php
Application/ ConfermaOrdine.php, AnnullaOrdine.php, DuplicaOrdine.php
DatiConfermaOrdine.php
Infrastructure/ OrdineRepositoryEloquent.php
Magazzino/
Application/ ImpegnaScorta.php, RilasciaScorta.php
Fatturazione/
Application/ GeneraFatturaDaOrdine.php, InviaFatturaSdI.phpPer tipo, su progetti piccoli o quando si sta introducendo la convenzione gradualmente:
app/
Actions/
Ordini/ ConfermaOrdine.php, AnnullaOrdine.php
Magazzino/ ImpegnaScorta.php
Data/ DatiConfermaOrdine.phpUna nota su una tentazione frequente: le action non hanno bisogno di un’interfaccia. A differenza del repository, dove l’interfaccia serve a sostituire l’implementazione, un’action ha una sola implementazione per definizione — è un pezzo di logica applicativa, non un adattatore verso un sistema esterno. Dichiarare ConfermaOrdineInterface aggiunge un file e nessuna libertà. Nei test si sostituisce la classe concreta, che Laravel risolve comunque dal container.
Passo 6 — Invocare la stessa action da più punti d’ingresso
Qui si raccoglie il beneficio principale. La stessa operazione, scritta una volta, viene usata da tutti i punti d’ingresso dell’applicazione — e la regola di business resta una.
Dal controller
final class ConfermaOrdineController
{
public function __invoke(
ConfermaOrdineRequest $request,
ConfermaOrdine $conferma, // iniettata dal container
): RedirectResponse {
$this->authorize('conferma', Ordine::class); // l'autorizzazione sta qui
try {
$ordine = $conferma->esegui(DatiConfermaOrdine::daRequest($request));
} catch (OrdineNonConfermabile $e) {
return back()->withErrors(['ordine' => $e->getMessage()]);
}
return to_route('ordini.show', $ordine->id())
->with('stato', 'Ordine confermato');
}
}Il controller è tornato a fare il suo mestiere: tradurre HTTP in dominio, gestire l’esito, produrre una risposta. Si noti che è anch’esso una classe a responsabilità unica — un invokable controller, una rotta sola — che è la stessa idea applicata al livello di trasporto.
// routes/web.php
Route::post('/ordini/{ordine}/conferma', ConfermaOrdineController::class)
->name('ordini.conferma');Dal comando Artisan
final class ConfermaOrdiniPagatiCommand extends Command
{
protected $signature = 'ordini:conferma-pagati {--limite=100}';
public function handle(
OrdiniDaConfermareQuery $daConfermare,
ConfermaOrdine $conferma,
): int {
$sistema = UtenteId::sistema(); // nessun utente autenticato: si passa esplicitamente
foreach ($daConfermare->esegui((int) $this->option('limite')) as $id) {
try {
$conferma->esegui(new DatiConfermaOrdine(
ordine: $id,
confermatoDa: $sistema,
quando: new \DateTimeImmutable(),
));
$this->components->info("Confermato {$id}");
} catch (OrdineNonConfermabile $e) {
$this->components->warn("Saltato {$id}: {$e->getMessage()}");
}
}
return self::SUCCESS;
}
}Dalla coda
Il job non contiene logica: trasporta i dati e chiama l’action. È la separazione che permette di collaudare l’operazione senza coda e la coda senza operazione.
final class ConfermaOrdineJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public bool $afterCommit = true; // non parte prima che la transazione chiamante abbia fatto commit
public function __construct(private readonly DatiConfermaOrdine $dati) {}
public function handle(ConfermaOrdine $conferma): void
{
$conferma->esegui($this->dati);
}
/** Evita doppie conferme se il job viene riprovato */
public function uniqueId(): string { return (string) $this->dati->ordine; }
}Da un pannello amministrativo
Anche dove si lavora con strumenti che generano l’interfaccia, l’operazione resta la stessa classe:
// In una risorsa Filament
Action::make('conferma')
->requiresConfirmation()
->visible(fn (Ordine $record) => $record->confermabile())
->action(function (Ordine $record) {
app(ConfermaOrdine::class)->esegui(new DatiConfermaOrdine(
ordine: OrdineId::da($record->id),
confermatoDa: UtenteId::da(auth()->id()),
quando: new \DateTimeImmutable(),
));
});Quattro punti d’ingresso, una regola. Il giorno in cui la conferma dovrà verificare anche il limite di credito del cliente, si modifica una classe e tutti e quattro si adeguano — che è precisamente ciò che non succede quando la logica è nei controller.
Una nota sui pacchetti che uniscono i ruoli
Esistono pacchetti — il più noto nell’ecosistema Laravel è lorisleiva/laravel-actions — che permettono alla stessa classe di funzionare come oggetto, controller, job, comando e listener, aggiungendo un trait. Sono comodi e fanno risparmiare i file intermedi visti sopra.
Il compromesso va però conosciuto: quella classe acquisisce metodi legati al trasporto (asController, asJob, le regole di validazione, l’autorizzazione) e torna a sapere da dove arriva la chiamata, che è esattamente ciò che la separazione voleva evitare. La mia preferenza, su progetti destinati a durare, è la classe PHP semplice con i job e i controller sottili come adattatori: sono dieci righe in più e un confine in meno da difendere. Su prototipi e progetti piccoli il pacchetto è una scelta ragionevole, a patto di sapere cosa si sta scambiando.
Passo 7 — Comporre le action
Le operazioni complesse si costruiscono con le action, non dentro un’action. Un’action può iniettarne altre, e questo è il meccanismo di riuso corretto — al posto dei trait, che condividono codice senza dichiarare dipendenze.
final readonly class EvadiOrdine
{
public function __construct(
private OrdineRepository $ordini,
private PrelevaDaMagazzino $preleva,
private GeneraDocumentoDiTrasporto $generaDdt,
private PrenotaRitiroCorriere $prenotaRitiro,
private Transazioni $transazioni,
private Eventi $eventi,
) {}
public function esegui(DatiEvasione $dati): Spedizione
{
// Confine transazionale UNICO, dichiarato nell'action più esterna
[$ordine, $spedizione] = $this->transazioni->esegui(function () use ($dati) {
$ordine = $this->ordini->perId($dati->ordine);
$this->preleva->esegui(new DatiPrelievo($ordine->righe()));
$ddt = $this->generaDdt->esegui(new DatiDdt($ordine));
$spedizione = $ordine->evadi($ddt, $dati->quando);
$this->ordini->salva($ordine);
return [$ordine, $spedizione];
});
// Chiamata esterna FUORI dalla transazione: un corriere lento
// non deve tenere aperta una transazione sul database
$this->prenotaRitiro->esegui(new DatiRitiro($spedizione));
$this->eventi->pubblica(new OrdineEvaso($ordine->id()));
return $spedizione;
}
}Le tre regole della composizione
Il confine transazionale appartiene all’action più esterna. Le action interne non aprono transazioni proprie — o, se le aprono, devono sapere che in Laravel le transazioni annidate si innestano con i savepoint, quindi non rompono il confine esterno ma non lo sostituiscono.
Le chiamate a sistemi esterni stanno fuori dalla transazione. API, invio email, scrittura su storage remoto: un timeout di tre secondi su un servizio esterno, dentro una transazione, significa tre secondi di lock sulle righe. È la causa più comune di deadlock in produzione.
Gli eventi si pubblicano dopo il commit. Un listener che parte prima del commit può leggere uno stato che non esiste ancora, o reagire a un’operazione che verrà annullata. In Laravel ci sono due strumenti per questo: $afterCommit = true su job e listener in coda, e DB::afterCommit() per il codice sincrono.
Quando la sequenza è lunga: la pipeline
Per i flussi con molti passi omogenei — una pratica che attraversa sei controlli, un’importazione con cinque fasi di trasformazione — una sequenza di chiamate diventa illeggibile e la pipeline di Laravel è più chiara, perché rende l’elenco dei passi un dato modificabile.
use Illuminate\Pipeline\Pipeline;
final readonly class ValutaRichiestaCredito
{
private const CONTROLLI = [
VerificaIdentita::class,
VerificaBanchaDati::class,
CalcolaPunteggio::class,
ApplicaRegoleEsclusione::class,
DeterminaEsito::class,
];
public function __construct(private Pipeline $pipeline) {}
public function esegui(RichiestaCredito $richiesta): EsitoValutazione
{
return $this->pipeline
->send(new ContestoValutazione($richiesta))
->through(self::CONTROLLI)
->then(fn (ContestoValutazione $c) => $c->esito());
}
}Ogni controllo è una classe con un solo metodo handle($contesto, $next), collaudabile da sola; aggiungere un passo significa aggiungere una riga all’elenco. È il pattern giusto quando i passi sono dello stesso tipo e l’ordine conta; è il pattern sbagliato quando i passi sono eterogenei, perché forza tutto a condividere un unico oggetto di contesto che diventa un contenitore indistinto.
Passo 8 — Testare
Le action sono la forma di logica applicativa più semplice da collaudare, perché hanno un ingresso, un’uscita e dipendenze dichiarate. La strategia su tre livelli evita sia i test fragili sia quelli inutili.
Il test dell’action: la regola, senza framework
// tests/Unit/Ordini/ConfermaOrdineTest.php
it('rifiuta la conferma di un ordine già annullato', function () {
$ordini = new OrdineRepositoryInMemoria();
$ordini->salva($ordine = OrdineFactory::annullato());
$conferma = new ConfermaOrdine(
ordini: $ordini,
impegnaScorta: new ImpegnaScortaFinta(),
transazioni: new TransazioniFinte(),
eventi: $eventi = new EventiFinti(),
);
expect(fn () => $conferma->esegui(new DatiConfermaOrdine(
ordine: $ordine->id(),
confermatoDa: UtenteId::nuovo(),
quando: new \DateTimeImmutable('2026-03-01 10:00'),
)))->toThrow(OrdineNonConfermabile::class);
expect($eventi->pubblicati())->toBeEmpty();
});
it('registra chi ha confermato e quando', function () {
$ordini = new OrdineRepositoryInMemoria();
$ordini->salva($ordine = OrdineFactory::inAttesaDiConferma());
$operatore = UtenteId::nuovo();
$istante = new \DateTimeImmutable('2026-03-01 10:00');
$confermato = (new ConfermaOrdine($ordini, new ImpegnaScortaFinta(),
new TransazioniFinte(), new EventiFinti()))
->esegui(new DatiConfermaOrdine($ordine->id(), $operatore, $istante));
expect($confermato->confermatoIl())->toEqual($istante)
->and($confermato->confermatoDa()->equals($operatore))->toBeTrue();
});Nessun database, nessuna richiesta HTTP, nessun orologio da congelare: l’istante arriva come parametro. Questi test girano in millisecondi e si rompono solo quando cambia la regola — che è l’unico motivo per cui un test dovrebbe rompersi.
Il test del punto d’ingresso: sottile e sul contratto
Il test HTTP non riverifica la regola: controlla che il percorso sia collegato, l’autorizzazione applicata e l’esito tradotto correttamente.
it('restituisce 403 a chi non ha il permesso di confermare', function () {
$this->mock(ConfermaOrdine::class)->shouldNotReceive('esegui');
$this->actingAs(UserFactory::operatoreSenzaPermessi())
->post("/ordini/{$id}/conferma")
->assertForbidden();
});
it('mostra l\'errore di dominio senza interrompere la navigazione', function () {
$this->mock(ConfermaOrdine::class)
->shouldReceive('esegui')
->andThrow(new OrdineNonConfermabile('Scorta insufficiente'));
$this->actingAs(UserFactory::responsabile())
->post("/ordini/{$id}/conferma")
->assertRedirect()
->assertSessionHasErrors('ordine');
});Qui il mock è legittimo e utile, perché l’oggetto del test è l’adattatore HTTP. Mockare l’action nei test di dominio, invece, è l’errore opposto: si finisce a verificare che un metodo sia stato chiamato anziché che l’operazione abbia funzionato.
Il test di integrazione: uno per flusso critico
Un terzo livello, deliberatamente sottile: per i flussi che contano, un test end-to-end con il database reale che verifichi che le parti combinate funzionino — transazioni, vincoli, eventi. Uno per operazione critica, non uno per ogni caso, perché sono i test più lenti e fragili dell’intera suite.
Passo 9 — Mantenere le action nel tempo
Una convenzione vive solo se è chiaro come si evolve. Tre situazioni ricorrono.
L’action che cresce. Supera le cinquanta o sessanta righe, o compare un parametro booleano che cambia il comportamento. I flag sono il sintomo più affidabile: esegui($dati, bool $senzaNotifica = false) significa che ci sono due operazioni dentro una. La soluzione è separarle, e se condividono una parte sostanziale, estrarre quella parte in una terza action iniettata da entrambe.
Le action quasi identiche. ConfermaOrdineWeb e ConfermaOrdineApi sono un errore di livello: la differenza sta nel trasporto, non nell’operazione, e va nei rispettivi controller. Se invece la differenza è nelle regole — conferma manuale contro conferma automatica — sono due operazioni diverse e i nomi devono dirlo.
L’action che non viene mai chiamata. Capita più di quanto si immagini, e la convenzione rende il problema visibile: una classe in Application/ senza chiamanti è codice morto, e si cancella. In una struttura a god service lo stesso metodo morto resta per anni, perché nessuno si accorge che nessuno lo chiama.
Oltre le action: l’idea applicata al resto del progetto
L’Action Pattern è un caso particolare di una convenzione più ampia — le single purpose classes — e Laravel offre già molti posti dove applicarla, spesso senza che se ne approfitti.
| Al posto di | Una classe per… | Strumento |
|---|---|---|
| Controller con sette metodi non correlati | Una rotta | Controller invocabile (__invoke) |
| Validazione nel controller | Un contratto di input HTTP | Form request |
| Query sparse nei controller | Un’interrogazione di lettura | Query object che restituisce DTO |
| Logica di presentazione in Blade | Una schermata | View model |
| Regole di validazione come closure | Un vincolo | Classe di regola (ValidationRule) |
| Conversioni ripetute negli accessor | Una trasformazione di attributo | Cast personalizzato |
| Trait condiviso fra più classi | Un comportamento con dipendenze dichiarate | Classe iniettata |
Il filo comune è sempre lo stesso: una classe, un motivo per cambiare, un nome che dice cosa fa. L’effetto cumulativo su un progetto di qualche anno è che la navigazione diventa per nome anziché per ricerca — si apre la cartella e si legge cosa c’è, invece di cercare in quale dei venti metodi di una classe stia la riga che interessa.
Quando l’Action Pattern è ceremonia inutile
Come tutte le convenzioni, applicata senza giudizio produce il problema opposto: centinaia di file da tre righe e una navigazione frammentata in cui per capire un flusso bisogna aprire nove classi.
Il CRUD non ha bisogno di action. CreaTagAction::esegui($dati) => Tag::create($dati) è un file, un binding e un test per avvolgere una riga. Se l’operazione non ha regole, non ha effetti collaterali e non viene chiamata da più punti, il controller che usa Eloquent direttamente è la scelta corretta e più leggibile.
Le operazioni invocate da un solo punto, senza logica, non guadagnano niente. Il valore dell’action nasce da almeno uno di tre fattori: regole di business da collaudare, più punti d’ingresso, effetti collaterali da coordinare. Se non c’è nessuno dei tre, la classe sta spostando codice senza aggiungere nulla.
Un’action per ogni metodo di un god service non è un refactoring. Spezzare OrdineService in ventotto classi da quattro righe produce la stessa logica con ventotto file. Il refactoring utile parte dai casi d’uso reali: tipicamente ventotto metodi collassano in sei o sette operazioni, e il resto si scopre essere codice morto o duplicato.
Il criterio riassuntivo, in una domanda da porsi prima di creare il file: questa classe mi permette di fare qualcosa che prima non potevo — collaudare una regola senza database, riusare l’operazione da un comando, cambiare un passo senza toccare gli altri? Se la risposta è no, il metodo dove sta ora va benissimo.
Sette errori che svuotano il pattern
- L’action che restituisce una Response. La lega al livello HTTP e la rende inutilizzabile da coda, comando e test. L’action restituisce dati; la risposta la costruisce il controller.
- Dipendenze nascoste nel corpo.
auth(),request(),now()dentro l’operazione: ognuna è un pezzo di contesto non dichiarato, e insieme rendono il test una simulazione di ambiente anziché una verifica di regola. - I flag booleani nella firma. Due comportamenti in una classe, con il doppio dei percorsi da collaudare e un nome che non li descrive né l’uno né l’altro.
- Metodi statici.
ConfermaOrdine::esegui(...)è comodo da chiamare e impossibile da sostituire nei test; inoltre le dipendenze finiscono dentro il metodo tramiteapp(), tornando al punto 2. - Trait per riusare logica fra action. Riuso senza dipendenze dichiarate e senza possibilità di sostituzione: se due action condividono un pezzo, quel pezzo è una terza action da iniettare.
- Autorizzazione dentro l’action. La rende inservibile dai contesti senza utente autenticato — comandi schedulati, webhook, importazioni. Le autorizzazioni stanno ai confini.
- Transazioni e chiamate esterne mescolate. Un’API lenta chiamata dentro una transazione tiene i lock aperti per tutta la sua durata. È il bug che si manifesta solo sotto carico, cioè nel momento peggiore.
Checklist di un’action ben fatta
- Il nome è un verbo all’imperativo più un oggetto, nel linguaggio del dominio.
- Nel nome non c’è una congiunzione.
- C’è un solo metodo pubblico, con la convenzione usata in tutto il progetto.
- Tutte le dipendenze sono dichiarate nel costruttore.
- Nel corpo non compaiono facade di contesto come
auth(),request()onow(). - L’input è un DTO tipizzato se i parametri sono tre o più.
- Non ci sono parametri booleani che cambiano il comportamento.
- Il valore di ritorno è un oggetto di dominio, un esito o
void, mai una Response. - L’autorizzazione sta al confine, non dentro.
- Il confine transazionale è dichiarato in un solo punto, nell’action più esterna.
- Le chiamate a sistemi esterni e la pubblicazione degli eventi avvengono fuori dalla transazione.
- Esiste un test che verifica la regola senza database.
- La classe sta sotto le cinquanta righe, o è chiaro perché no.
Conclusioni
L’Action Pattern non introduce concetti nuovi: riorganizza il codice che già esiste secondo un criterio diverso. Invece di raggruppare per entità — tutto quello che riguarda gli ordini in una classe, che cresce senza limite — si raggruppa per operazione, e ogni classe trova una dimensione naturale perché un’operazione, a differenza di un’entità, è finita.
I benefici concreti sono tre, e si notano tutti dopo qualche mese: la stessa regola di business serve controller, comandi, code e pannelli amministrativi senza essere riscritta; i test diventano verifiche di regole anziché simulazioni di ambiente, e di conseguenza rapidi; e l’elenco delle classi in una cartella diventa l’elenco leggibile di ciò che il sistema sa fare, che è la forma di documentazione che non si disallinea mai.
Il consiglio operativo per iniziare, su un progetto già avviato, è di non convertire niente in blocco. Si prende il metodo di controller più lungo, o il flusso duplicato fra web e comando Artisan, e si estrae quell’unica operazione: nome, DTO, dipendenze nel costruttore, un test senza database. Se il risultato è che quel flusso è diventato più chiaro e il suo test più rapido, la convenzione si è guadagnata il diritto alla seconda estrazione. Se invece sono comparsi tre file per spostare dieci righe, la risposta è altrettanto utile: quel pezzo di codice stava bene dov’era.

