firefly/web is LaraFly's HTTP layer: #[RestController]/#[Controller] routing compiled to a
RouteManifest, parameter binding with #[Valid] interception, content negotiation (JSON for data, HTML
for views), and RFC-7807 error rendering — all dispatched through native Laravel routes, inside the real
HTTP-kernel middleware pipeline.
#[RestController] is a #[Component] stereotype (it extends Firefly\Container\Attributes\Component),
so a controller is discovered by the same component scan as any other bean and gets constructor
dependency injection for free — no separate controller-registration mechanism:
use Firefly\Web\Attributes\{RestController, RequestMapping, GetMapping, PostMapping, PathVariable, QueryParam, RequestHeader, RequestBody};
use Firefly\Validation\Valid;
#[RestController]
#[RequestMapping('/accounts')]
final class AccountsController
{
public function __construct(private readonly AccountService $accounts) {}
#[GetMapping('/{id}', name: 'accounts.show')]
public function show(
#[PathVariable] int $id,
#[QueryParam(default: 'summary')] string $view,
#[RequestHeader('X-Trace')] ?string $trace = null,
): array {
return $this->accounts->summarize($id, $view);
}
#[PostMapping(status: 201)]
public function create(#[Valid] #[RequestBody] CreateAccountRequest $body): array
{
return $this->accounts->open($body);
}
}The RouteScanner (routing metadata) and the component scan (DI wiring) are two separate passes over
the same class — a #[RestController] never has to declare its own route registration.
#[Controller] is to #[RestController] what Spring's @Controller is to @RestController: same routing,
different intent. It extends #[RestController], so RouteScanner's IS_INSTANCEOF filter finds it with
no scanner change, its routes compile into the same RouteManifest, and constructor DI is identical. What
differs is what the method returns and how the response is built.
use Firefly\Web\Attributes\{Controller, GetMapping};
use Firefly\Web\View\ModelAndView;
use Illuminate\Contracts\View\View;
#[Controller]
final class WelcomeController
{
#[GetMapping('/', name: 'welcome')]
public function index(): View
{
return view('welcome', ['name' => 'Ada']); // rendered as text/html
}
#[GetMapping('/about')]
public function about(): ModelAndView
{
return ModelAndView::of('about', ['version' => '1.0'])->withStatus(200);
}
}ModelAndView is a view name plus its model, resolved through the application's view factory. It exists
for a handler that should not reach for the view() helper — one under test, or one in a package that must
not depend on illuminate/view — and carries of(), withModel(), withStatus() and withHeader(). If no
view factory is bound, returning one fails loudly rather than rendering nothing.
Two deliberate boundaries:
- A bare
stringreturn is not a view name.#[RestController]methods legitimately return strings that must negotiate to JSON, and the meaning of a return value must not depend on the class that declares it. Explicit beats magic. - A
#[Controller]may still return an array or a DTO, which negotiates to JSON exactly as before — the same latitude Spring gives a@Controllermethod carrying@ResponseBody.
#[RequestMapping(path: '...')] is class-level and prepends a base path to every method mapping on the
controller. Each method carries exactly one verb attribute, all implementing the Mapping marker
interface so the scanner discovers them polymorphically (IS_INSTANCEOF) with no per-verb scanner code:
| Attribute | HTTP method |
|---|---|
#[GetMapping] |
GET |
#[PostMapping] |
POST |
#[PutMapping] |
PUT |
#[PatchMapping] |
PATCH |
#[DeleteMapping] |
DELETE |
Each verb attribute accepts path (relative, joined to the class-level base), status (the default
response status — 200 for the read verbs, customizable e.g. status: 201 for #[PostMapping]), and
name (an optional Laravel route name).
Each controller-method parameter is bound by exactly one of these attributes, plus two binding conventions that need no attribute at all:
| Attribute | Source | Notes |
|---|---|---|
#[PathVariable(name?)] |
route segment | always required; defaults to the parameter name |
#[QueryParam(name?, default?, required?)] |
query string | not required by default |
#[RequestBody] |
request body | reads/decodes via the negotiated MessageConverter; combine with #[Valid] to gate on BeanValidator |
#[RequestHeader(name?, default?)] |
HTTP header | not required |
#[UploadedFile(name?)] |
multipart/form-data file |
binds a framework-neutral Firefly\Web\Http\UploadedFile (filename/mimeType/size + lazy contents()/store()) |
Two parameters need no attribute at all:
- A typed class parameter (e.g. a service interface) with no binding attribute resolves as a
container service —
$container->make($type)— so a controller method can pull an extra collaborator straight from the container without adding it to the constructor. - A scalar parameter with no binding attribute defaults to a query parameter keyed by the parameter's own name, required unless the parameter itself is optional.
Path and query values are coerced to the parameter's declared scalar type (int/float/bool); an
uncoercible value or a missing required input raises InvalidRequestException (HTTP 400,
MISSING_PARAMETER / TYPE_CONVERSION_ERROR), rendered like any other FireflyException — see
Error Handling.
Adding #[Valid] to a #[RequestBody] parameter gates the DTO on the Bean-Validation constraint layer
before the DTO is hydrated: the raw decoded body is validated by Firefly\Validation\Constraint\BeanValidator
against the DTO class's compiled rules, and only on success is the DTO constructed (from the raw body,
not the validator's validated() subset, so an unconstrained property is never silently dropped). A
failure throws the kernel's ValidationException — HTTP 422, rendered as application/problem+json
with one FieldError per failed field.
Constraints are per-property attributes on the DTO's constructor-promoted parameters (or plain typed
properties): #[NotBlank], #[NotEmpty], #[NotNull], #[AssertTrue]/#[AssertFalse], #[Size],
#[Min]/#[Max], #[Digits], #[Pattern], #[Past]/#[Future], #[Email], plus 16 financial-domain
wrappers (#[Iban], #[Bic], #[Swift], #[Isin], #[Cusip], #[RoutingNumber], #[Luhn],
#[CurrencyCode], #[CountryCode], #[LanguageTag], #[UuidValue], #[Phone], #[PostalCode],
#[Percentage], #[Money], #[DecimalScale]) and a #[Rules] escape hatch for any raw Illuminate rule
string or ValidationRule object. #[Valid] on a nested DTO property cascades one object level deep
(dot-prefixed nested keys), with a cycle guard against self-referential DTOs. See
Validation for the full constraint catalogue and the underlying Rule objects.
final class CreateAccountRequest
{
public function __construct(
#[NotBlank]
#[Iban]
public readonly string $iban,
#[NotBlank]
public readonly string $owner,
) {}
}ResponseFactory decides what to do with a controller's return value in this order:
| Return value | Response |
|---|---|
A Symfony or Illuminate Response (including JsonResponse) |
passed through untouched |
A Responsable |
toResponse($request) |
A ModelAndView |
resolved through the view factory, rendered text/html; charset=UTF-8 |
A View or any Renderable |
render(), rendered as text/html; charset=UTF-8 |
An Htmlable |
toHtml(), rendered as text/html; charset=UTF-8 |
Anything else (array, JsonSerializable, Arrayable, scalar) |
written by the negotiated MessageConverter |
The HTML arms are why a server-rendered page is possible at all. Before they existed, a Blade View was
neither a SymfonyResponse nor a Responsable, so it fell through to the converter chain and was
json_encoded — and because a View exposes no public properties, every returned view became the body
{} with HTTP 200 and Content-Type: application/json, silently.
Data negotiation is JSON-native: the only shipped MessageConverter is JsonMessageConverter
(application/json and any +json suffix type). The converter is chosen from the request's Accept
header — parsed for q-values with a header-order tiebreak — falling back to the first (JSON) converter when
nothing matches or Accept is absent. Request bodies are read the same way, keyed off Content-Type.
MessageConverterRegistry is an ordinary container binding (guarded #[ConditionalOnMissingBean]-style
via if (! $app->bound(...))), so an application can register additional MessageConverters — XML
support is deferred to this seam rather than shipped in M6 (tracked for SP-6).
RouteScanner — the one reflection site in packages/web/src — walks #[RestController] classes and
compiles each verb-mapped method into a RouteDescriptor (HTTP method, joined path, controller/method,
default status, optional route name, and a pure-array parameter-binding plan). RouteManifestCompiler
var_exports the descriptors into a require-able PHP file; RouteManifest::load() reads it back
reflection-free. At boot, RouteWiringPass (phase WiringPasses) registers one native Laravel route per
descriptor and hands each one a dispatch closure (ControllerDispatcher) — so route listing, URL
generation, and Laravel's own route-caching machinery all apply to LaraFly routes unmodified.
!!! note "Known-latent: manifest compilation, config ordering, and negotiation scope"
- App manifests need no hand-wiring. RouteManifest, ConstraintManifest and
ExceptionHandlerRegistry are each resolved through Firefly\Context\Scan\AppScan: the artifact
firefly:cache compiled if it exists, otherwise an in-process scan of firefly.scan.paths, otherwise
empty. An application therefore never has to compile and bind these itself — it did have to before the
scan fallback existed, and until then an uncached app 404'd every route it owned.
- route:cache interplay. Because dispatch runs through ordinary native Laravel routes, Laravel's
own route:cache works unmodified once those routes are registered — there is no separate LaraFly
route cache to keep in sync with it.
- Component-scan path ordering. Auto-configuration discovers #[Component] beans — including
WebFilters — from a component scan whose paths are read at provider-register time. Any
application config that sets firefly.scan.paths must therefore already be loaded before
providers register. This is exactly the ordering Laravel itself guarantees (LoadConfiguration
runs before RegisterProviders), so a config file is the correct place for this key — but a
programmatic override made after providers have registered is too late to affect the scan.
- Deep/array #[Valid] cascade is bounded. Nested-DTO validation cascades exactly one object
level per #[Valid] property; it does not recurse into arrays/collections of DTOs, and a
self-referential #[Valid] chain expands only that one level (guarded against infinite descent).
- XML content negotiation is deferred to the MessageConverter seam described above (SP-6);
only JSON ships in M6.
Apache-2.0 © Firefly Software Solutions Inc.