firefly/admin is a server-rendered browser dashboard over the actuator — the Spring Boot Admin analogue, with one
structural difference: it is not a separate monitoring application you deploy and register instances with. It is
Blade views inside the application it reports on, which is why it can read the endpoint registry directly, and
why its access model matters as much as it does.
It arrives with the runtime family — firefly/firefly requires it, so a composer create-project firefly/skeleton project already has it and firefly new --with admin only makes the dependency explicit in
your own composer.json. Add it directly if you took the packages à la carte:
composer require firefly/adminThen open /firefly. Installed is not enabled: firefly.admin.enabled defaults to app.debug, so the
package being present costs a production deployment nothing. That default, not the absence of the package, is
what stands between the dashboard and the internet — which is why the warning below matters more than the
install line above.
!!! warning "The access model is the whole security model"
firefly.admin.enabled defaults to app.debug, because the dashboard bypasses the actuator's
exposure model and its own URL is therefore the only thing in front of beans, env and
conditions. It ships no authentication of its own. Read
Access: the whole security boundary before enabling it
outside debug.
Most pages are a view over one ActuatorEndpoint's payload; four read the container instead. The menu groups
them the way an operator thinks rather than the way the packages are laid out — what is it doing right now,
what did it wire at boot, what is its data, how is it configured — because a flat list of seventeen links
is a worse menu than four short ones.
| Group | Page | Path | Endpoint | Answers |
|---|---|---|---|---|
| Runtime | Overview | /firefly |
several | Is it healthy, what is it doing, and what did it wire? |
| Runtime | Health | /firefly/health |
health |
Every indicator this process registered, with its own status and details |
| Runtime | Metrics | /firefly/metrics |
metrics |
Counters, timers and gauges, with their current measurements |
| Runtime | HTTP traffic | /firefly/http |
httpexchanges |
The most recent requests this application served |
| Wiring | Beans | /firefly/beans |
beans |
Every bean the container registered, with the stereotype that declared it |
| Wiring | Bean graph | /firefly/graph |
beans |
How your beans depend on one another — see Bean Graph |
| Wiring | Conditions | /firefly/conditions |
conditions |
Which auto-configurations applied, and which backed off because you supplied your own |
| Wiring | Routes | /firefly/mappings |
mappings |
The compiled route table the dispatcher serves from |
| Wiring | Scheduled | /firefly/scheduled |
scheduledtasks |
Methods registered by #[Scheduled], with the cron or interval that drives them |
| Configuration | Environment | /firefly/env |
env |
Resolved firefly.* configuration, flattened to dotted keys, with secrets masked |
| Configuration | Config properties | /firefly/configprops |
configprops |
Every #[ConfigProperties] DTO the application bound, with the values it resolved |
| Configuration | Caches | /firefly/caches |
caches |
The cache stores this application has configured |
| Configuration | Loggers | /firefly/loggers |
loggers |
Log channels and their levels, with a control to change one |
| Configuration | Feature switches | /firefly/settings |
— | Every framework switch, where its value came from, and — outside production — a control. Off by default; see below |
| Data | Datasource | /firefly/datasource |
— | Connections, connection reuse, and the compiled #[Transactional] contract |
| Data | Browse data | /firefly/data |
— | The records behind your repositories — see Data Browser. Off by default |
| Data | Entity map | /firefly/data-map |
— | The entities and the foreign keys between them, drawn |
The four pages with no endpoint read the container rather than the actuator, and each decides its own visibility: an entry that led to "there is nothing here" is worse than no entry.
A page whose endpoint is not registered in this process — or is switched off — is hidden from the menu rather
than offered as a link that lands on an apology, and requesting it directly answers 404 with a page saying which
endpoint it needed. That matters because the actuator's endpoints are conditional: metrics disappears when
firefly.observability.metrics.enabled is false, and configprops, caches and httpexchanges exist only if the
package contributing them is installed. The menu has to be built from what this process actually registered, so it
is.
The Overview is the page an operator leaves open, so it answers the three questions that matter without a click: the
aggregate health status with every indicator beside it, the /actuator/info runtime fragment flattened to one row
per fact (with byte-ish keys formatted as sizes rather than printed as raw JSON), bean/route/condition/task counts,
the current metrics, the last eight HTTP exchanges, and whether this process booted compiled or scanned —
read from AppScan::cachedFile(...), not from configuration.
Values are formatted for reading, not for scraping: 2.0 MB rather than 2097152, 31.2 ms rather than 0.0312.
That formatting lives in the dashboard, never in the endpoint, because the JSON surface has to keep returning
machine-readable numbers — Prometheus scrapes it.
AdminEndpointReader holds the ActuatorRegistry and invokes each ActuatorEndpoint bean directly:
public function read(string $id, array $subPath = [], array $query = []): ?array
{
$endpoint = $this->registry->get($id);
if ($endpoint === null || ! $this->has($id)) {
return null;
}
try {
$response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query));
} catch (Throwable) {
return null;
}
return $response === null || is_string($response->body) ? null : $response->body;
}What is not in that method is any mention of ExposureModel, and that is the single most important thing about
this package. firefly.management.endpoints.web.exposure.include defaults to health,info, so fetching
/actuator/beans or /actuator/env over HTTP 404s — as it should.
The dashboard needs none of that. It renders what the process already knows, in-process, so it shows pages the
HTTP surface deliberately does not expose, and the JSON surface stays secure-by-default. Exposing beans,
conditions and env to every anonymous caller just so a browser could read them would be exactly the wrong trade.
The per-endpoint kill switch is honoured, and the asymmetry is the design:
| Key | Means | Dashboard |
|---|---|---|
firefly.management.endpoint.{id}.enabled |
"this endpoint is off" — a statement about the endpoint | honoured; the page disappears from the menu |
firefly.management.endpoints.web.exposure.include |
"this endpoint is unpublished" — a statement about the HTTP surface | bypassed; the dashboard is not the HTTP surface |
A throwing endpoint is caught and reported as null rather than allowed to take the page down with it — the same
fail-safe discipline HealthEndpoint applies to indicators, for the same reason: one broken contributor should
degrade its own panel, not the dashboard.
firefly.management.endpoint.health.show-details defaults to never, and that default is right: it stops an
anonymous HTTP caller learning your database host from a failed connection. Applying that HTTP disclosure policy
to the dashboard, though, produced a Health panel whose entire content was an apology telling the operator to go
and change a config key.
The dashboard reads HealthContributorRegistry directly instead, calling each indicator in isolation so one that
throws is reported DOWN with its exception class and message and nothing else is affected — exactly what
HealthEndpoint's own fail-safe read does. The JSON /actuator/health response is unchanged and still withholds
components until show-details is always.
Because the dashboard bypasses exposure, its own URL is the only thing standing in front of beans, env and
conditions. That is why it must not be on by default in production, and why the enable flag is written the way
it is:
enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)),firefly.admin.enabled defaults to the value of app.debug. An application already running with debug on is
already serving stack traces to whoever asks and is a development environment by definition, so a dashboard there
discloses nothing that was not already disclosed. An application with debug off has made the opposite statement
about itself and must opt in explicitly. Setting the key always wins over the debug default, in both directions
— you can turn the dashboard off in a debug environment, and on in a production one.
!!! warning "Turning it on outside debug is only half the job"
firefly.admin.enabled = true with app.debug = false mounts a dashboard that renders your bean graph, your
resolved configuration and your route table at a known URL, to anyone who can reach it. The dashboard ships
no authentication of its own — it has no code dependency on firefly/security at all, exactly as
firefly/actuator does not. An application that enables it outside debug must put the route behind its own
auth middleware.
firefly/security's HttpSecurityFilter is a global middleware pushed onto Laravel's HTTP-kernel stack, so it runs
for the dashboard's natively-registered routes exactly as it runs for your controllers. Locking it down is pure
configuration:
'firefly' => [
'admin' => [
'enabled' => true, // explicit: this deployment wants the dashboard with app.debug off
'base-path' => '/firefly',
],
'security' => [
'enabled' => true,
'http' => [
'enabled' => true,
'rules' => [
['pattern' => 'firefly', 'access' => 'hasRole:ADMIN'],
['pattern' => 'firefly/*', 'access' => 'hasRole:ADMIN'],
],
],
],
],Both patterns are needed: firefly alone does not match firefly/env. Any other middleware works equally well —
a VPN-only route group, basic auth, an SSO gateway — the requirement is that something stands in front of the
path, not that it be firefly/security.
When the dashboard is disabled, AdminRouteRegistrar registers nothing at all: there is no route to guess at
and no handler to reach, and php artisan route:list does not list one.
The dashboard's routes were mounted on the router with no middleware at all, which in Laravel means no
session and no ValidateCsrfToken. Every @csrf in these views was therefore decorative: a curl -X POST
with no token against /firefly/loggers was accepted and changed the log level, and the same held for every
write the data browser and the settings console added.
A form that renders a CSRF field while the route ignores it is worse than one that renders none, because
it looks protected. The routes now carry EncryptCookies, AddQueuedCookiesToResponse, StartSession,
ShareErrorsFromSession and ValidateCsrfToken.
!!! note "The classes, not the web group name"
Naming the group and guarding on hasMiddlewareGroup('web') looked right and attached nothing: this
registrar runs inside the framework's boot pipeline, before the application's RouteServiceProvider
defines that group, so the guard was false at registration time and silently produced an empty list — a
fix that appeared applied and was not. Referring to the classes needs no group and no ordering
assumption, and each is skipped if the installation does not have it.
!!! warning "Your session driver has to persist"
An array session driver is discarded at the end of the request, so the token a form renders can never
match the one the next request checks and every dashboard POST answers 419. The skeleton now ships
SESSION_DRIVER=file for exactly this reason; file needs no service, only the storage directory the
framework already writes to.
Laravel's CSRF middleware returns early when `runningUnitTests()` is true, so no feature test can prove
this either way — which is how the hole survived being written. `tests/AdminCsrfTest.php` therefore
asserts the middleware is *attached*, and the behaviour was verified over real HTTP: tokenless → `419`,
token with its session → `302` and the write lands.
AdminRouteRegistrar is a BootPass at BootPhase::WiringPasses, order 60 — one step after
ActuatorRouteRegistrar's 50, because it reads the registry that pass populates. It mounts two routes:
GET {base} name: firefly.admin.index
GET|POST {base}/{page} name: firefly.admin.page where page: [A-Za-z0-9\-_/]*
They are registered natively on the illuminate Router, not declared with #[GetMapping], for the same reason the
actuator's and OpenAPI's are: firefly.admin.base-path has to be
settable per application, and an attribute route bakes its literal path into a compiled RouteDescriptor. Leading
and trailing slashes on the configured base path are optional, and an empty one falls back to firefly.
It also backs off silently in one more case that is easy to miss. Blade is required to render the dashboard and
is not a dependency of the package, so a JSON-only deployment with no view binding gets no routes rather than
routes that would fatal on first request; the JSON actuator remains the management surface there.
The views are plain Blade with inline CSS and system fonts. There is no npm step at install time and no CDN at
request time — a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in
exactly the isolated environments where you most want to look at one. (The one other browser surface LaraFly ships,
firefly/openapi's console, reaches the same conclusion by a different route: it serves the official
Swagger UI from the application's own origin out of a composer package.)
Under PHP-FPM every request is a different process, and three pages inherit that.
- Changing a log level affects this process only. The control calls the same endpoint
POST /actuator/loggers/{name}does, which mutates the current process's Monolog handlers. The next request is a different process and reverts to the configured level. Changelogging.channelsfor anything that must persist — the page says so, in place, rather than letting anyone believe they have changed production logging. - Metrics are only as durable as your registry. The default
SimpleMeterRegistrykeeps meters in process memory, so the dashboard sees only its own request. Setfirefly.observability.metrics.storeto a cache store to accumulate across workers. - HTTP traffic has the same shape, more sharply. The in-memory exchange ring under PHP-FPM is not merely stale
but always empty, because the request rendering the page has not been recorded yet — the filter records on the way
out.
firefly.observability.httpexchanges.storeis what makes that panel non-empty. While you are there, add the dashboard's own base path tofirefly.observability.httpexchanges.exclude: a polling dashboard will otherwise evict every genuine request from a 100-row ring and show you nothing but itself. The framework does not add it for you, because reaching into another package's configuration key to guess at its mount point is the kind of hidden coupling that breaks the day somebody changes it.
| Key | Default | Meaning |
|---|---|---|
firefly.admin.enabled |
app.debug |
Mount the dashboard at all. An explicit value wins in both directions. |
firefly.admin.base-path |
'/firefly' |
Where it is mounted. Leading and trailing slashes optional; empty falls back to firefly. |
firefly.admin.title |
app.name (else 'LaraFly') |
The name shown in the sidebar and the page title. |
firefly.admin.refresh-seconds |
10 |
How often a live page reloads itself. Floored at 2: a shorter interval reloads faster than the page renders, so the countdown would never finish and the dashboard would hammer the application it is meant to be observing. |
firefly.admin.theme |
'auto' |
auto | light | dark. Anything unrecognised falls back to auto (follow the operating system) rather than rendering unstyled. |
firefly.admin.graph.max-nodes |
220 |
The ceiling past which the bean graph lists relations instead of drawing them. Clamped to a minimum of 0, which suppresses the diagram entirely. |
firefly.admin.pages.exclude |
'' |
CSV of page slugs to refuse. This is a refusal, not a menu preference: an excluded page is hidden and its URL 404s — hiding env from the menu achieves nothing if the URL still answers. Use overview for the index page. |
firefly.admin.datasource.probe |
true |
Whether the datasource page may open a configured connection to report that it answers. |
firefly.admin.datasource.wizard |
false |
The connection wizard. Off by default and refused in production — see below. |
firefly.admin.settings.enabled |
false |
The feature-switch console. |
firefly.admin.settings.writable |
false |
Whether that console has controls. Ineffective in production. |
The firefly.admin.data.* keys are documented separately, in Data Browser,
because the browser is gated independently of everything above: firefly.admin.enabled does not switch it on,
and neither does app.debug.
Four questions an operator asks at 3am that this dashboard could not answer:
- Which database am I talking to? Driver, host, port and database per connection, with
passwordmasked by the same masker the actuator'senvendpoint uses — the page is behind the dashboard's gate, and a connection array dumped verbatim would put the database password on that URL. - Is it up? One connection is probed per page load — the default, or the one named by
?probe=— because opening a socket can hang against a firewalled host, and a page that opened every configured connection would take the slowest one's timeout to render, on the page you opened because something is wrong. What comes back is the server version, or the driver's own message. - What does pooling mean here? PHP has no connection pool, and a "pool size" gauge would be an invented number.
What exists is PDO's
ATTR_PERSISTENT, reported as what it is — with the note that under php-fpm the effective pool size is your worker count, decided by the process manager, and that real pooling in front of Postgres is pgbouncer's job. - What did
#[Transactional]compile to? One row per proxied method with its propagation, isolation, timeout and connection. It existed only as a compiled artifact underbootstrap/cache.
firefly.admin.datasource.wizard adds a form that opens a connection you have not configured yet and reports
the server version or the driver's own error, plus the config/database.php block to paste. It collapses the
edit-.env, clear-cache, reload, read-a-useless-error loop into one round trip.
It is off by default and refused outright when app.env is production, and no configuration key lifts that. A
form that opens a socket to a host somebody typed is a request-forgery primitive by construction, and its failure
messages distinguish "refused" from "timed out" well enough to map a private network. It is POST-only for the same
reason — a link, an image tag or a prefetch must never be able to reach it — and it writes nothing: the result
is a snippet, with the password always an env() call and never the value that was typed.
!!! note "Why the errors are useful at all"
The connection is opened with getPdo() before the test query. Going through selectOne() puts Laravel's
reconnect wrapper in the way, which rethrows Lost connection and no reconnector available for a wrong
password, a closed port and a typo in the host alike. Forcing the socket and unwrapping to the innermost
exception is what turns the button into something worth pressing.
/firefly/data-map draws every browsable entity as a box with its columns, and every foreign key as a labelled
edge, from the same discovery the data browser walks. Boxes are links into their own
records.
A hasMany and the belongsTo facing it are one key seen from two ends, so each is drawn once — pointing from
the table that holds the key to the table it references, which is also what the arrow means. Relations the
browser cannot express as a single column comparison (a pivot, a polymorphic type column) are listed on each record
page but are not drawn, because a line with no join to name would be decoration. Entities with no relations at all
are drawn: a standalone table is a fact about the model, and a diagram that quietly dropped it would let a reader
conclude the application has fewer tables than it does.
It is behind the browser's own switch, not the dashboard's: a schema diagram names every table and column an application has, which is the shape of its data even though it is not the data.
/firefly/settings is the one page that changes the application rather than describing it, and it is gated
accordingly.
| Gate | Default | What it decides |
|---|---|---|
firefly.admin.settings.enabled |
false |
Whether the page exists at all |
firefly.admin.settings.writable |
false |
Whether it has controls as well as readings |
app.env is production |
— | Not a configuration key. Writes are refused, whatever the two above say |
The third gate is deliberately unconfigurable. That is the difference between "we made it safe" and "we made it
configurable to be safe", and only the first survives someone copying a .env.
It is a feature switch, not a remote configuration endpoint. The list of switches is fixed and framework-owned,
so a crafted POST naming app.key or a database host finds nothing to write — the method cannot express it. Each
row shows where its value came from: config (yours), default (the framework's), or console (this page).
A change is written to one JSON file under bootstrap/cache, filtered on the way in as well as out — a
hand-edited entry cannot introduce a key the console would have refused — and merged over configuration during the
provider's register(). Deleting that file restores your configured values exactly. Nothing is ever written to
.env: a config cache would disagree with it until someone cleared it, the file is routinely read-only in a
container image, and a web form that edits the file holding your database password is not a feature.
!!! note "Why register() and not a boot pass"
Every settings object in this framework is built once from configuration and held for the process. Applying the
overrides from the dashboard's own boot pass wrote the file and showed the new state on the page while
/openapi.json kept answering 200 — a merge after the first read changes nothing. register() runs before any
boot pass and before any bean resolves, which is the only point at which the merge is true.
| Concern | Plain Laravel | LaraFly (firefly/admin) |
|---|---|---|
| A management UI | none first-party; Telescope is a request debugger, Horizon a queue dashboard — neither reports on wiring or configuration | one dashboard over the actuator's own endpoints |
| Browsing your data | none; Nova and Filament are paid or app-scale admin frameworks you build screens in | a Django-style browser over the repositories you already declared, off by default |
| Feature switches | a config file and a deploy | a gated console, with the production gate not configurable |
| Where it runs | Telescope/Horizon each add tables, a service provider and a middleware group | Blade views over beans that already exist; no storage of its own, nothing recorded |
| Data source | a recorder writing to the database | the live ActuatorRegistry, read in-process at render time |
| Enabling it safely | TelescopeServiceProvider::gate() — a closure you write |
firefly.admin.enabled defaulting to app.debug, plus your own middleware when you override it |
- No instance registry. Spring Boot Admin is a separate server that many applications register with, giving one console across a fleet. This is a per-instance dashboard, which is what makes the in-process read possible; a fleet view would need a different design and is not planned.
- No write operations besides the log level, the data browser and the feature switches.
/cachesis read-only for the same reason it is read-only on the JSON surface —firefly/actuatorcarries no code edge tofirefly/securityand so cannot say who asked. when-authorizedhealth details degrade toneveron the JSON surface (see Actuator); the dashboard sidesteps it entirely by reading the contributor registry.
See also: Actuator for the endpoints themselves, Observability for the metrics
and HTTP-exchange stores the dashboard renders, Bean Graph for the one page that is more than
a table, and Data Browser for the Django-style view over your own repositories — which is
off by default and does not inherit firefly.admin.enabled.