Laravel Comparison¶
LaraFly is additive: it layers a Spring-Boot-shaped application model on top of Laravel 13 — it never forks, wraps, or replaces the framework underneath. Every LaraFly capability is a normal Composer package registering normal Laravel service providers; a LaraFly app is still, in every respect a Laravel developer would recognize, a Laravel app. This page is the same kind of comparison pyfly's Spring Boot comparison draws for Python, mapped onto Laravel instead.
At a glance¶
| Concern | Raw Laravel | LaraFly |
|---|---|---|
| Entry point | Service providers registered in bootstrap/providers.php, wired by hand |
The same providers, plus auto-discovered AutoConfiguration classes assembled by a kernel-decided BootPass pipeline |
| Dependency injection | app()->bind()/app()->singleton() in a provider's register() |
#[Component]/#[Service]/#[Repository]/#[Configuration] stereotypes on the class itself; compiled component scan resolves constructor dependencies |
| Configuration | config('mail.host') (array access, untyped) |
#[ConfigProperties] DTOs bound from a config subtree — typed, fail-fast on a missing/mismatched key |
| HTTP routing | routes/web.php/routes/api.php route files |
#[RestController] (JSON) / #[Controller] (HTML) + verb attributes (#[GetMapping], …), compiled to a RouteManifest, still dispatched through native Laravel routes |
| Validation | FormRequest::rules() (array rules) |
#[Valid] parameter interception over a Bean-Validation-style constraint model, still backed by Laravel's validator |
| Transactions | DB::transaction(fn () => …) (closure-scoped) |
#[Transactional] on a class/method — declarative propagation/isolation/rollback rules, manual beginTransaction/commit/rollBack under the hood |
| Events | Event::listen() / #[AsEventListener]-style Laravel listeners, in-process only |
Two distinct surfaces: the in-process bus (#[AsEventListener]) and a broker-backed EDA bus (#[EventListener]) — see below |
| CQRS | Not a first-class concept — Laravel has no command/query bus | CommandBus/QueryBus with a bounded pipeline (validate → authorize → invoke → metrics) and #[CommandHandler]/#[QueryHandler] |
| Security | Auth guards + route middleware (->middleware('auth'), Gate::allows()) |
Spring-Security-6-shaped SecurityContext + deny-by-default HttpSecurity URL DSL + method security (#[PreAuthorize]/#[Secured]/#[RolesAllowed]) |
| Operations | Nothing built in — health checks and metrics are typically bespoke or a package | firefly/actuator (health/info/env/beans/mappings) + firefly/observability (Prometheus/Micrometer-JSON metrics), both Spring Boot Actuator/Micrometer analogues |
| Interactive login | Auth::routes(), Breeze or Fortify — a scaffold copied into your application, which you own and maintain from then on |
A SecurityContextPersistenceFilter that loads the context from the session, a FormLoginFilter on /login, the framework's own login page, a LogoutFilter, remember-me, and an entry point that negotiates a redirect against a 401 |
| Signing in with an external provider | Socialite — an OAuth2 client you drive from a controller you write | firefly/security-oauth2-client: OIDC login as a filter pair, provider presets, discovery, PKCE, id-token validation, an OidcUser injected into your action, RP-initiated logout, client credentials and Http::oauth2Client() |
| Being the provider | Passport for full OAuth2, Sanctum for first-party tokens | firefly/security-oauth2-server: registered clients in memory or Eloquent, /oauth2/authorize with PKCE and a consent page, /oauth2/token with three grants, introspection, revocation, userinfo, JWKS, both .well-known documents, RS256/ES256 keys from firefly:oauth2:keys, rotated through jwt.previous_keys |
| Distributed tracing | Nothing built in; Telescope is local-only and is not a trace | A Tracer/Span port with an OpenTelemetry adapter, a W3C traceparent continued at the server filter and injected on the Http client and both CQRS buses; an event published through the in-memory or queue bus carries it in the envelope headers, and every delivery is traced, a broker's included |
| Structured logging | Monolog, configured per channel by hand | firefly.logging.structured.format = json/ecs/logstash, with the correlation, request, trace and span ids attached by processors on every channel the framework resolves |
| Repository vocabulary | Eloquent query-builder calls, written out per use case | Spring Data's: derived queries, #[Query], query by example, #[Modifying], #[Projection], #[Lock], #[EntityGraph], Page/Slice/Pageable/Sort, and a typed DataAccessException family instead of a raw QueryException |
| Browser testing | Dusk — a real browser against a separately served application | Pest 4 + Playwright over the Testbench-booted skeleton in-process (composer test:browser), with the same SQLite database and the same container |
Entry point: service providers vs. auto-configuration¶
Laravel boots by registering whatever service providers bootstrap/providers.php lists, in the order they
appear (or, for package-discovered providers, roughly alphabetically). LaraFly's firefly/context sits on
top of that: every capability package's provider still exists and is still discovered by Laravel exactly the
same way — but its register() method does nothing except buffer its BootPass contributions into a
PendingBootPasses collector. The shared FireflyKernel drains that buffer once it is actually resolved and
runs each phase (scanning, condition evaluation, bean registration, wiring passes) in a kernel-decided order —
so which provider Laravel happened to instantiate first is irrelevant. See
Architecture for the full pipeline diagram.
Dependency injection: app()->bind() vs. stereotypes¶
Laravel's container is powerful but explicit — a binding lives in a provider, separate from the class it
binds. LaraFly's firefly/container layers PHP 8 attributes onto Illuminate\Container: mark the class
itself with #[Component] (or #[Service]/#[Repository]/#[Configuration], all specializations of it)
and a compiled component scan registers it, resolves constructor dependencies by type, and honors
#[Primary]/#[Qualifier]/#[Order] for multi-implementation ports — see
Dependency Injection. Nothing stops you from also using app()->bind()
directly; the two coexist.
Configuration: config() vs. #[ConfigProperties]¶
config('mail.port') is untyped array access — a typo or a missing key returns null silently. LaraFly's
firefly/config adds a fail-fast typed accessor ($config->int('mail.port', 25), throws on a required key
that's absent or the wrong type) and #[ConfigProperties('mail')] DTOs that bind a whole config subtree onto
a plain readonly class at once — see Configuration. Laravel's config/*.php
files remain the source of truth; LaraFly reads them, it doesn't replace them.
HTTP: route files vs. #[RestController]¶
Laravel routes live in routes/*.php, separate from the controller class. firefly/web's
#[RestController] + #[GetMapping]/#[PostMapping]/etc. attributes put the route on the controller
method itself; a RouteScanner compiles them into a RouteManifest at cache time (or scans in-process when
there is no cache), and that manifest is what actually registers native Laravel routes at boot — there is no
custom dispatch mechanism underneath. #[Controller] is the HTML sibling: same routing, but a returned
View/ModelAndView/Htmlable renders as text/html instead of negotiating to JSON. See
Web Layer.
Validation: FormRequest vs. #[Valid]¶
A Laravel FormRequest::rules() returns an array of string/rule-object rules, evaluated when the request is
resolved. firefly/validation's #[Valid] (paired with firefly/web's #[RequestBody]) intercepts a
method parameter and runs it through the same validator — the difference is where the rule set lives (a
validate() call or a rule class, not a rules() array method) and that failures render as RFC-7807
ProblemDetails instead of Laravel's default redirect/JSON-errors response. See
Validation.
Data & transactions: DB::transaction() vs. #[Transactional]¶
DB::transaction(fn () => …) scopes a transaction to a closure — propagation and rollback rules are
whatever you write inline. firefly/data's #[Transactional] attribute (a Spring @Transactional analog)
declares propagation (REQUIRED, REQUIRES_NEW, NESTED, …), isolation, read-only, and
rollback/no-rollback exception lists on the class or method itself; a proxy generated at scan time drives
the boundary through manual beginTransaction()/commit()/rollBack() so a caught exception can still be
committed when it matches noRollbackFor. See Transactions.
Events: one Laravel surface vs. two LaraFly surfaces¶
Laravel has one event mechanism: Event::dispatch()/listeners, in-process, synchronous by default. LaraFly
keeps that surface (#[AsEventListener], from firefly/context) and adds a second, unrelated one:
firefly/eda's broker-backed bus, where #[EventListener] subscribes to an event-type pattern
('user.*') rather than a PHP class, and delivery can be in-memory, queued (async, cross-process), or — via
the SP-4 broker adapters — RabbitMQ/Postgres/Kafka. These are deliberately not the same thing; see
Event-Driven Architecture § Two event surfaces — not one for
the full distinction, and CQRS for how a committed domain event bridges onto the EDA bus
as an integration event.
CQRS: a concept Laravel doesn't have¶
Laravel has no built-in command/query bus — a "command" in Laravel usually just means an Artisan console
command. firefly/cqrs adds a genuine CQRS mediator: CommandBus::send()/QueryBus::ask(), each running a
bounded pipeline (correlate → validate → authorize → invoke → metrics, plus a cache stage on the query side),
with handlers discovered via #[CommandHandler]/#[QueryHandler]. A #[Transactional] command handler gets
transaction semantics for free from the firefly/data proxy — the CQRS layer writes no interception code of
its own. See CQRS (Command/Query).
Security: middleware vs. deny-by-default HttpSecurity¶
Laravel's default posture is permissive-by-default: a route is public unless you attach ->middleware('auth')
or an ability check. firefly/security inverts that: the HttpSecurity URL DSL builds an ordered rule list
and denies anything unmatched (401 anonymous, 403 authenticated) — the same deny-by-default model as Spring
Security. On top of that, method security (#[PreAuthorize], #[PostAuthorize], #[PreFilter],
#[PostFilter], #[Secured], #[RolesAllowed]) rides the same compiled interceptor chain #[Transactional]
uses, so it holds on any stereotyped bean — a #[Service], a #[CommandHandler], a #[Repository] —
wherever that bean is called from, and additionally at the controller dispatcher and the CQRS bus. The
expression evaluator is a closed whitelist tokenizer with no eval. Underneath it all, one first-party
SecurityContext/Authentication principal model backs every mechanism: session-persisted form login, HTTP
Basic, remember-me, JWT, the OAuth2 resource server, and OpenID Connect login. Laravel's own auth guards and
middleware still work underneath — firefly/security is a stricter layer atop them, not a fork. See
Security.
Login: a scaffold you own vs. a filter you configure¶
In Laravel, interactive login is a starter kit. Breeze, Jetstream or Fortify copy controllers, routes, Blade views and form-request classes into your application; from that moment they are your code, and the next security fix in the upstream kit is something you read about and port yourself:
// routes/auth.php, as a starter kit writes it into your application
Route::middleware('guest')->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
});
Route::middleware('auth')->group(function () {
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
});
In LaraFly, login is filters that already exist plus a configuration block.
SecurityContextPersistenceFilter (-94) loads the SecurityContext from the session and saves it after the
response; FormLoginFilter (-92) handles POST /login — CSRF first, then the AuthenticationManager;
LogoutFilter (-93) handles POST /logout; RememberMeAuthenticationFilter (-83) restores a session
from the cookie. The login page is the framework's own Blade view unless you point view at yours, and
the reference configuration ships the whole block commented out at its defaults:
'form_login' => [
'enabled' => env('FIREFLY_SECURITY_FORM_LOGIN_ENABLED', false),
// 'login_page' => '/login',
// 'login_processing_url' => '/login',
// 'username_parameter' => 'username',
// 'password_parameter' => 'password',
// 'default_success_url' => '/',
// 'always_use_default_success_url' => false,
// 'failure_url' => '/login?error',
// 'view' => 'auth.login',
],
There is no generated code to maintain, and no route file to keep in step: an upgrade to
firefly/security upgrades the login flow. The trade is the usual one — you configure rather than edit, so
a behaviour the settings do not expose is a feature request rather than a two-line patch in your own
controller. See Security.
OAuth2: two packages, two directions¶
In Laravel, the two directions come from unrelated packages with unrelated shapes. Socialite makes you an OAuth2 client, but it hands you a user object and leaves the session, the account linking and the redirect handling to a controller you write. Passport makes you an authorization server; Sanctum issues first-party tokens that are not OAuth2 at all:
// routes/web.php — Socialite gives you the round trip; the rest is yours
Route::get('/auth/google/redirect', fn () => Socialite::driver('google')->redirect());
Route::get('/auth/google/callback', function () {
$external = Socialite::driver('google')->user();
$user = User::updateOrCreate(
['email' => $external->getEmail()],
['name' => $external->getName(), 'google_id' => $external->getId()],
);
Auth::login($user, remember: true);
return redirect('/dashboard');
});
In LaraFly both directions are the same shape as everything else: a package you install, a block of
configuration, and filters that join the chain. firefly/security-oauth2-client adds
OAuth2AuthorizationRequestRedirectFilter (-89) and OAuth2LoginAuthenticationFilter (-88), so
/oauth2/authorization/{registrationId} and /login/oauth2/code/{registrationId} are served without a
route or a controller of yours existing; PKCE, the single-use state and nonce, and id-token validation
against the issuer's JWKS are the filters' job. What reaches your code is a principal:
<?php
declare(strict_types=1);
namespace App\Http;
use Firefly\Security\Core\Attributes\AuthenticationPrincipal;
use Firefly\Security\OAuth2\Client\User\OidcUser;
use Firefly\Web\Attributes\GetMapping;
use Firefly\Web\Attributes\RestController;
#[RestController]
final class ProfileController
{
/** @return array{sub: string, email: string|null, name: string|null} */
#[GetMapping('/api/me')]
public function me(#[AuthenticationPrincipal] OidcUser $user): array
{
return [
'sub' => $user->getSubject(),
'email' => $user->getEmail(),
'name' => $user->getFullName(),
];
}
}
firefly/security-oauth2-server points the same protocol the other way and adds
OAuth2AuthorizationServerFilter (-82), serving /oauth2/authorize with PKCE and a consent page,
/oauth2/token with three grants, introspection, revocation, userinfo, JWKS and both .well-known
documents. The two packages are independent installs — an application can be a relying party, an
authorization server, both, or neither. See OAuth2 Client and
OAuth2 Authorization Server.
Operations: actuator & observability¶
Laravel ships no health-check or metrics endpoint out of the box — most teams either hand-roll one or reach
for a package. firefly/actuator mounts health, info, env, beans, conditions, mappings,
loggers, scheduledtasks, caches and configprops under /actuator, secured entirely by ordinary
HttpSecurity configuration and — apart from health and info, the two the default include list already
names — unexposed (404) until added to firefly.management.endpoints.web.exposure.include;
firefly/observability adds metrics, prometheus, process and httpexchanges — a first-party
Prometheus-0.0.4 + Micrometer-JSON exposition, HTTP auto-instrumentation, and the real CqrsMetrics recorder.
They are the Spring Boot Actuator and Micrometer analogues respectively, both opt-in Composer packages, both
secure-by-default, and firefly.management.server.port puts the whole surface on a second listener. See
Actuator and Observability.
Tracing and logs: Telescope vs. a propagated trace¶
In Laravel, there is no distributed tracing. Telescope is an excellent local debugger — requests, queries, jobs and mails for one application, stored in that application's own database — but it does not propagate anything, so two Laravel services handling the same user action record two unrelated stories. The correlation id that would tie them together is something every team eventually writes by hand:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class AddRequestId
{
public function handle(Request $request, Closure $next): Response
{
Log::withContext(['request_id' => $request->header('X-Request-Id') ?? (string) Str::uuid()]);
return $next($request);
}
}
In LaraFly that is the framework's job, and it reaches further than one process. TracingFilter
(-110) extracts the inbound W3C traceparent and starts a SERVER span with it as the parent,
so a request that arrives inside a trace continues it; the ids are published to Laravel's Context and
Request::$attributes; and the same context is carried across five boundaries — inbound HTTP, an INTERNAL
span per CQRS message, a PRODUCER span stamping the header into the envelope on the in-memory and queue
buses, a CONSUMER span on every delivery (a broker's included, through the shared SubscriberRegistrySink;
the broker publishers do not stamp the header yet — see Known-latent), and
a CLIENT span on every outbound Http call, which injects the header again. Logging is the same story told
in a second place: TraceContextLogProcessor puts trace_id and span_id on every record
beside the correlation and request ids, and one key chooses the line format:
'logging' => [
'structured' => [
// …
'format' => env('FIREFLY_LOG_FORMAT', ''),
// …
// 'channels' => ['stack', 'stderr'],
// …
],
],
json is Monolog's JsonFormatter, ecs is Elastic Common Schema 8, logstash is Monolog's
LogstashFormatter, and '' keeps Laravel's plain-text lines — anything else refuses to boot rather than
logging in a format nobody asked for. None of it costs anything until you opt in: the shipped Tracer is a
NoOpTracer, and the OpenTelemetry adapter binds itself only when the SDK is installed. See
Tracing and Logging.