# Laravel Truss
> Full documentation for Laravel Truss, a live database structure viewer for Laravel. Structure only, never data.
---
# Configuration
Source: https://trussphp.com/filament/configuration/
The diagram reads Truss's config, which is the single place to change what is drawn. Publish it if you have not already:
```bash
php artisan vendor:publish --tag=truss-config
```
Everything about which tables appear, how deep focus reaches and what the diagram shows is [configured there](/reference/configuration/), not here.
## Hidden tables
Truss keeps framework plumbing out of the diagram through its own `truss.excluded_tables`, so a panel on 16 tables may draw 8 of them. **The footer says so**, reading `8 of 16 tables` rather than presenting a filtered diagram as the whole schema.
Where `truss.reveal_excluded` allows it, a **Show hidden tables** toggle draws the excluded ones muted. That is on by default in `local` and off elsewhere, matching `truss.enabled` and the `viewTruss` gate.
**Both switches live in Truss's config and the plugin adds none of its own.** So the toggle being absent in production is a setting rather than a bug, and excluding a table to keep it off a shared panel keeps working: there is no query parameter and no plugin option that puts it back.
## The one option
The link to the project in the page header, which is on by default and off in one call:
```php
$panel->plugin(FilamentTrussPlugin::make()->documentationLink(false));
```
It is on by default because it is where a developer goes next, and removable because a permanent link to somebody's project in somebody else's admin panel is a billboard.
That is the entire configuration surface of the plugin. Everything else is Truss's.
**Theming is not configured at all.** The diagram follows the panel's palette by reading it, so there is nothing to set and no `truss.theme` to publish for it. See [Theming](/filament/theming/).
---
# Filament panel
Source: https://trussphp.com/filament/
import ThemedImage from '../../../components/ThemedImage.astro'
import panelLight from '../../../assets/filament/panel-light.jpg'
import panelDark from '../../../assets/filament/panel-dark.jpg'
[`albertoarena/filament-truss`](https://github.com/albertoarena/filament-truss) adds a **Database schema** page to a Filament panel: the same live ER diagram Truss draws, as a page of the panel rather than a frame around something else.
**Structure only, never row data.** Tables, columns, indexes, foreign keys and column defaults. That is Truss's promise unchanged: living inside an admin panel does not relax it, and the plugin has no write path of any kind.
## What it is
**A Filament page**, built from the panel's own components and styled by the panel's own theme, so it reads as part of the admin rather than a visitor in it. The diagram takes its colours and radii from the panel's own CSS custom properties, so a custom theme needs no configuration and light and dark follow the panel with no reload.
## What it is not
**Not the Truss dashboard in an iframe.** That was considered and rejected: a frame around another page inherits none of the panel's chrome, breaks its dark mode, and reads as a bolted-on tab. If a frame is all you want, Truss already ships a dashboard and you can link to it.
**Not a second renderer.** The page calls `Truss::payload()`, the facade method, and hands the array to Truss's own frontend embedded in the page. There is no schema endpoint of its own and no reimplementation of the diagram, so nothing here can drift from what Truss draws everywhere else.
**Not a way in.** It exposes nothing Truss does not already expose, and it gains no write path by living inside an admin panel.
## Requirements
| | |
| --- | --- |
| PHP | `^8.2` |
| Laravel | `^12.0 \| ^13.0` |
| Filament | `^5.0` |
| Laravel Truss | `^1.13.1` |
Filament 4 is deliberately not supported.
Laravel Truss is where the schema, the diff and the structural findings come from, and it comes with the plugin as a regular dependency. Truss itself stays free of any Filament dependency, which is why these are two packages rather than one.
The floor is a patch above that line's first release for a reason that belongs to this page: Truss v1.13.1 sizes the toolbar by the toolbar rather than by the window, and a panel is always narrower than the window it sits in. On v1.13.0 a panel with an expanded sidebar could leave the toolbar too little room while the responsive steps still believed they had the whole window, and the **Filter** field, the one control with no minimum width, absorbed the difference.
## Next
- [Installation](/filament/installation/), including who can see the page
- [Theming](/filament/theming/), which needs nothing from you
- [Open the diagram on a table](/filament/open-on-a-table/), the button a resource can offer
- [Configuration](/filament/configuration/), hidden tables and the one option this plugin adds
---
# Installation
Source: https://trussphp.com/filament/installation/
```bash
composer require albertoarena/filament-truss
```
Laravel Truss comes with it as a regular dependency, so there is nothing else to install and no asset to publish.
## Register the plugin
```php
use AlbertoArena\FilamentTruss\FilamentTrussPlugin;
use Filament\Panel;
use Filament\PanelProvider;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentTrussPlugin::make());
}
}
```
The page appears in the panel navigation as **Database schema**, at `/database-schema` under your panel's path. There is nothing to configure for the diagram itself: it follows Truss's config and the panel's theme.
## Who can see the page
**A Filament page never passes through Truss's middleware**, so the plugin asks the same questions itself. The page is visible exactly when the Truss dashboard would be visible to the same user, and in the same order.
1. **`truss.enabled`**, which defaults to **on in `local` and off everywhere else**. Set `TRUSS_ENABLED=true` to run it anywhere else.
2. **In `local`, that is all.** Truss leaves the dashboard open there and so does this page.
3. **Everywhere else, the `viewTruss` gate.** Truss ships a default that admits the emails in `TRUSS_ALLOWED_EMAILS`, and an application can define its own instead:
```php
Gate::define('viewTruss', fn ($user) => $user->isAdmin());
```
:::note[Installed it and see nothing in staging?]
That is configuration rather than a bug, and almost always the first rule. `truss.enabled` is off outside `local` until you turn it on.
:::
Being allowed into the panel is not the same as being allowed to read the database structure, and the plugin never treats it as such. Both switches are Truss's, so a viewer who can see the Truss dashboard can see this page, and a viewer who cannot, cannot.
See [Authorization](/guides/authorization/) for how the gate works and what to put in it.
---
# Open the diagram on a table
Source: https://trussphp.com/filament/open-on-a-table/
import ThemedImage from '../../../components/ThemedImage.astro'
import focusLight from '../../../assets/filament/focus-button-light.jpg'
import focusDark from '../../../assets/filament/focus-button-dark.jpg'
A resource can offer a button that opens the diagram with **its own table already focused**. Truss reads `focus` from the query string and applies it on load, so the whole feature is a URL:
```
/admin/database-schema?focus=books
```
**Nothing of the plugin's runs in the browser for this.** There is no JavaScript to publish, no asset to build and nothing added to the payload. It is also why the feature cannot drift: the parameter is Truss's, and the plugin only writes it.
## Opting a resource in
The trait is the short way, and it works because every Filament resource can answer `getModel()`:
```php
use AlbertoArena\FilamentTruss\Actions\Concerns\HasViewInSchemaAction;
use Filament\Resources\Resource;
class BookResource extends Resource
{
use HasViewInSchemaAction;
// ...
}
```
Then put the action wherever the panel wants it. In a page header, beside Create:
```php
use App\Filament\Resources\Books\BookResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBooks extends ListRecords
{
protected static string $resource = BookResource::class;
protected function getHeaderActions(): array
{
return [
BookResource::viewInSchemaAction(),
CreateAction::make(),
];
}
}
```
**Put the trait on a base resource class and every resource extending it is opted in**, which is why it is a trait rather than a copied closure.
## Without the trait
`ViewInSchemaAction` is an ordinary `Filament\Actions\Action`, so it goes anywhere an action goes: a page header, a table row action, an action group.
```php
use AlbertoArena\FilamentTruss\Actions\ViewInSchemaAction;
ViewInSchemaAction::make()->forModel(Book::class);
ViewInSchemaAction::make()->forTable('books');
```
Being an ordinary action, it takes the ordinary methods too: `->label()`, `->icon()`, `->color()`, `->link()`, `->button()`. The defaults are **View in schema**, a grey outlined circle-stack icon, and the panel's own styling.
:::note[It is per table, not per record]
Focus narrows the diagram to a table and its neighbours, so every row of Books points at the same place. A row action is a legitimate placement, it just does not focus "this book".
:::
## When the button is not there
**It removes itself rather than greying out**, in three cases. In each one there is nothing the viewer could do about it, and a disabled control invites them to try.
| Case | Why |
| --- | --- |
| The panel has no schema page | A resource registered on two panels where only one has the plugin. Building the URL would otherwise throw. |
| The viewer may not see the page | The page's own rule, which is [Truss's rule](/filament/installation/#who-can-see-the-page). Being allowed to list Books is not being allowed to read the database structure. |
| Truss excludes the table | `?focus=` naming an [excluded table](/filament/configuration/#hidden-tables) matches no drawn table and is ignored, so the button would land on an unfocused diagram and read as broken. |
## Worth knowing
- **The focused URL is an address.** It bookmarks, it shares and it survives a reload, because the state is in the query string rather than in the page.
- **Depth is Truss's**, from `truss.focus.default_depth`. The link does not set it, and the plugin adds no way to set it per button.
See [Focus and filter](/guides/focus-and-filter/) for what focus does to the diagram.
---
# Theming
Source: https://trussphp.com/filament/theming/
import ThemedImage from '../../../components/ThemedImage.astro'
import amberLight from '../../../assets/filament/accent-amber-light.jpg'
import amberDark from '../../../assets/filament/accent-amber-dark.jpg'
import tealLight from '../../../assets/filament/accent-teal-light.jpg'
import tealDark from '../../../assets/filament/accent-teal-dark.jpg'
The diagram **reads the panel's own CSS custom properties at runtime**. It follows the panel it is installed in, including one it has never seen, with no configuration, no build step and no palette to keep in step.
There is nothing to do, which is the point of the page.
:::note[Checked rather than reasoned about]
Every claim below was verified in a browser on **17/09/2026**, against Truss v1.13.0, Filament 5.8.1, Laravel 13 and Chrome. The screenshots were taken from the same panel on 18/09/2026.
:::
The same page, twice, with one line of the panel changed and nothing else touched:
**The health badge is red in both**, which is the second half of the story and is covered below.
## What follows the panel
**The primary colour.** With `->colors(['primary' => Color::Teal])`, the rule under the toolbar, the focused table's border, the focus ring, the checkbox accent, the zoom control and its slider, the legend keys, a utility button's open state and the diff button all go teal. Nothing is left on the default amber.
**The grey scale.** `->colors(['gray' => Color::Slate])` moves the diagram background, the entity fill, the hairlines, the blueprint grid and the entity text with it.
**Dark mode**, instantly and with no reload, because the panel's own toggle is what the diagram listens to.
**The typeface.** `->font('Poppins')` takes the heading, the toolbar labels, the inputs and the footer.
**The layout.** Top navigation, full content width and a collapsing sidebar were each run: the diagram box ends where the page ends and nothing overflows sideways. Its height is measured from the page rather than assumed, so a panel with a taller heading or a breadcrumb gets the right answer.
**A custom compiled theme**, from `php artisan make:filament-theme` and `viteTheme()`, which replaces Filament's own stylesheet entirely. Everything above still holds.
## What does not follow, deliberately
**The diagram itself stays monospaced**, and so do the legend's `PK` and `FK` keys, whatever the panel's font is. Those are notation rather than interface, and a schema read in a proportional face is harder to scan. It is a line rather than an oversight.
**Severity colours stay severe.** Warnings and errors take Filament's `--warning-*` and `--danger-*` rather than the primary, so a red health badge stays red on a teal panel. A severity is not an accent.
**`PK` and `FK` badges** are entity text and take the grey scale, not the primary.
## The one caveat
**Colours come from `->colors()`, not from the theme's own CSS.**
Overriding Tailwind's `--color-gray-*` inside `resources/css/filament//theme.css` changes nothing here. That is not a gap in the plugin: Filament emits its `--gray-*` custom properties from `colors()`, so a Tailwind-level override moves neither the panel's own chrome nor the diagram. Both ways were measured.
The rule worth remembering: **whatever changes Filament's palette changes the diagram, and whatever does not, does not.**
## There is no theming config here
The plugin adds no theming setting, and **`truss.theme` does not reach this page**. That config colours [Truss's own dashboard](/guides/theming/) at `/truss`, and it is read on the server, while a Filament panel decides its theme in the browser. The separation is deliberate rather than pending.
What is promised is that the page reads the panel's properties, so a theme nobody anticipated arrives with the right values. Whether a particular grey step reads well at a particular size is a judgement, and that is the one thing here nobody has automated.
---
# Installation
Source: https://trussphp.com/getting-started/installation/
Wondering whether anyone else runs this? [In the wild](/in-the-wild/) collects
what other developers have written about Truss, with a link to every source.
## Requirements
- PHP 8.2 or higher
- Laravel 12 or higher
Truss is installed and run against real open-source Laravel applications, and
[what happened in each one](/reference/tested-applications/) is published,
including the two where it did not work at first.
## Install via Composer
For local use, Truss is a development and inspection tool, so install it as a dev dependency:
```bash
composer require albertoarena/laravel-truss --dev
```
The service provider is auto-discovered by Laravel. There is nothing to publish to get started.
:::caution[Running on staging or production]
A `--dev` install is excluded from `composer install --no-dev` builds, which is how most servers deploy. So a dev install never reaches a non-local server, and `/truss` returns 404 there. To run Truss gated on staging or production, install it as a regular dependency instead:
```bash
composer require albertoarena/laravel-truss
```
Then enable and authorize it as described in [Authorization](/guides/authorization/).
:::
## Publish the config (optional)
To customise Truss, publish its configuration file to `config/truss.php`:
```bash
php artisan vendor:publish --tag=truss-config
```
Every option is documented inline and has a sensible default. See the [Configuration reference](/reference/configuration/).
## That is it
By default Truss is enabled in the `local` environment only. Start your app and open:
```
/truss
```
You should see your schema as an ER diagram. Head to the [Quick start](/getting-started/quick-start/) for a tour of the interface, or [Authorization](/guides/authorization/) to run it safely in a non-local environment.
:::note
Truss reads the **live** database connection, so make sure your app's database is migrated and there is a schema to draw. If no connection is reachable, Truss falls back to replaying your migrations on an in-memory SQLite database and shows a banner saying so.
:::
---
# Quick start
Source: https://trussphp.com/getting-started/quick-start/
By default Truss is enabled in the `local` environment only. Start your app and open `/truss` (the prefix is configurable). You will see your schema as an ER diagram: each table is an entity box with its columns, native types, and `PK` / `FK` badges, and foreign keys are drawn as crow's-foot relationships.
## Try it live
The frame below runs the real Truss frontend on a fictional sample schema, so you can try everything on this page without installing anything. Drag to pan, scroll or pinch to zoom, and click a table name to focus or export it.
[Open the demo in its own tab](/demo/) for more room. It contains structure only, no real data (there is none).
## Moving around
The diagram behaves like a map:
- **Pan** by dragging.
- **Zoom** by scrolling, pinching (on touch), or using the zoom slider.
- **Fit** frames the whole diagram in one click. Auto-fit also runs whenever the content changes, never below a readable zoom, so a large schema stays legible and you pan around it.
## Filtering and focusing
- **Filter** narrows the diagram to tables whose name matches what you type.
- **Focus** reduces the diagram to one table and its foreign-key neighbours, up to a configurable depth. The focused table is centred and highlighted. This is the main tool for keeping large schemas readable.
Filter and focus compose: focus operates on whatever the filter left in.
Your current view is reflected in the URL (for example `/truss?focus=projects`), so you can bookmark or share a focused or filtered view and it reopens in that state.
## Reading the diagram
- **Type labels** show the native database type (for example `varchar(255)`) by default. Toggle **Laravel types** to see best-effort short labels (for example `string`).
- **`enum` / `set` columns** are compacted to the keyword so a long value list does not blow out the column. Click the `enum` label to see the full list of allowed values.
- **Self-referential foreign keys** (a column pointing back at its own table, such as `parent_id` on `categories`) are marked with a `self-ref` note on the column rather than a looping line, which keeps the diagram tidy.
- The **legend** (in the top-right) explains the `PK` / `FK` badges and the crow's-foot cardinality.
- A **theme toggle** cycles automatic, light, and dark. Automatic follows your operating system.
## Exporting a table
Click a **table name** in the diagram to open a small menu for that table:
- **Focus this table** reduces the diagram to it and its neighbours (the same as picking it in the Focus box). When the table is already focused, this item becomes **Unfocus this table**, which returns to the full diagram.
- **Copy JSON** copies its full structure to the clipboard.
- **Download JSON** saves its full structure (columns, keys, indexes) as `.json`.
- **Download CSV** saves a flat, spreadsheet-friendly column list (name, type, nullable, default, and a `PK` / `FK` key column) as `.csv`.
- **Download Markdown** saves the table as a Markdown section (a column table plus its indexes and foreign keys) as `.md`, ready to paste into a README or wiki.
Exports are generated in your browser from the schema Truss already loaded, and contain **structure only, never row data**, consistent with the rest of Truss.
## Exporting the diagram
The **export** button (the download icon in the top-right) saves the whole current selection, whatever the filter and focus are showing, either as an image or as a structured text file:
- **Export PNG** downloads a high-resolution raster (`truss-schema.png`, or `truss-.png` when a table is focused), handy for pasting into docs or chat.
- **Export SVG** downloads a vector version that stays crisp at any size and opens in vector editors.
- **Data dictionary (Markdown)** downloads the whole selection as a Markdown data dictionary (`truss-schema.md`, or `truss-.md` when focused), one section per table.
- **DBML** downloads the selection as a DBML file (`truss-schema.dbml`) that opens in [dbdiagram.io](https://dbdiagram.io) and other DBML tools. Types are passed through as-is (a best-effort, lossy mapping), and relationships are included only when both tables are in the current view.
The image exports are rendered entirely in your browser, embed the diagram font so the text is correct anywhere, and match your current light or dark theme. Every export is structure only.
## Multiple connections
Out of the box Truss shows your application's default database connection. If you configure more than one visualizable connection (see [`connections`](/reference/configuration/#connections)), a connection switcher appears in the toolbar. Switching re-fetches and re-renders that connection's schema without a full page reload, and the choice is kept in the URL so a view can be shared. Each connection is scoped to its own database, and per-connection `excluded_tables` let you hide different tables on each.
See it in the [multiple-connections demo](/demo/multi-connection/), which switches between a main application database and a separate module database.
## Try it on your own schema
The demos above run against a sample. To see your own tables without installing anything, open [draw your own schema](/demo/your-schema/) and paste a `mysqldump --no-data`, or the output of `php artisan truss:export --format=json`. It is parsed in your browser, so nothing is uploaded, and any `INSERT` statements in the file are counted and discarded rather than read.
## Next steps
- [Authorization](/guides/authorization/): run Truss safely in staging or production.
- [Configuration reference](/reference/configuration/): every option and its default.
---
# Accessibility
Source: https://trussphp.com/guides/accessibility/
Truss draws a diagram, and a diagram is the part of an interface most easily left mouse-only. From **v1.9.0** the dashboard answers the keyboard, names its controls, and describes the view it is showing to assistive technology.
This page says what is supported, how it is checked, and, just as importantly, what is not covered yet.
:::caution[This is not a conformance claim]
v1.9.0 closes the specific WCAG Level A keyboard failures found in the dashboard. It is not an audit of every success criterion in every state of the page, so Truss does **not** claim WCAG conformance. See [What is not covered yet](#what-is-not-covered-yet).
:::
## Keyboard
Everything in the toolbar is a native control and has always been reachable by **Tab**. What changed in v1.9.0 is the diagram itself.
Three things inside the rendered diagram announce themselves as buttons: a **table name**, an **enum type label**, and a **health marker**. They now behave like buttons.
- **Enter** or **Space** activates any of the three, opening the same menu a click opens. Space no longer scrolls the page out from under you.
- Opening a menu from the keyboard **moves focus into it**, so its actions are reachable without a mouse.
- **Escape** closes the open menu and **returns focus to whatever opened it**, so you are never dropped at the top of the document. Focus is handed back the same way after you run an action from the menu.
- Each trigger has a **visible focus ring**. It is an outline rather than a change of colour, since hover already uses colour and telling two colours apart is exactly what some users cannot do.
The Focus picker is a combobox with its own keyboard model, described in [Focus and filter](/guides/focus-and-filter/#searching-for-a-table).
## Screen readers
- The rendered diagram **names and describes itself**. Truss emits Mermaid's `accTitle` and `accDescr`, which become the SVG's `` and ``, and the description tracks the view you are actually on: how many tables and relationships are drawn, the active filter, and the focused table with its depth.
- The Focus picker follows the **ARIA combobox with listbox** pattern: focus stays in the text box, the active option is tracked with `aria-activedescendant`, and a polite live region announces how many tables match as you type.
- The **zoom slider** has a real accessible name. It previously carried only a tooltip, which assistive technology is not required to expose, so it announced as an unlabelled slider.
## How it is tested
Automated and manual, because neither alone is enough.
- **axe-core** scans the dashboard and its overlays against the WCAG 2.2 AA rule tags in the browser suite, as its own CI step on every push. A failure names the offending element, not just the rule.
- **Keyboard specs** cover activating each trigger by key, focus returning on Escape and after a menu action, and the whole state machine of the Focus picker.
- **Contrast ratios are computed in a unit test** from the shipped theme tokens, with no browser involved.
- A **manual pass** with VoiceOver in Safari, axe DevTools in light and dark, 200 percent browser zoom, the narrow layout where the toolbar folds behind the `⋯` button, and a real phone. It found four defects the automated checks could not, including a match highlight that failed contrast at 4.10:1 and focus being dropped after a menu action.
## What is not covered yet
Named because a vague claim is worse than an honest gap.
- **The diagram has no text alternative.** The SVG carries a name and a summary of the view, but the structure it draws is not available as text on the page. An accessible structure view is on the [roadmap](/roadmap/).
- **Non-text contrast (1.4.11) is unaudited.** The hairline tokens used on control boundaries sit under 3:1.
- **Contrast inside the SVG** is not evaluated by axe at all, and has not been measured criterion by criterion.
- **Criteria never audited here**, including dragging movements for pan, target size outside the Focus picker, and whether focus is obscured when it lands on a table scrolled out of view.
## Custom themes
Anything said here is scoped to the **shipped defaults**. [`truss.theme`](/guides/theming/) lets you set arbitrary colours, so a custom palette can fail contrast no matter what the package ships. If you re-theme the dashboard, check your own values.
## Reporting a problem
Accessibility bugs are bugs. [Open an issue](https://github.com/albertoarena/laravel-truss/issues/new/choose) and say what you were using (screen reader and browser, keyboard only, zoom level), what you expected, and what happened instead.
---
# Truss as AI context
Source: https://trussphp.com/guides/ai-context/
A coding agent guesses at your schema. It invents a `users.full_name` column that is really `name`, or a `status` string that is really an integer. Truss already knows the real structure, so hand it over: the same deterministic, structure-only export that feeds CI doubles as grounding context for Claude Code, Cursor, or any agent. It stays structure only, never row data.
Three flags turn the export into context worth pasting or piping into an agent, and annotations add the meaning a type cannot carry.
## Annotations
Types and foreign keys say what the shape is, not what it means. That `status = 1` is paid, that `total_amount` is integer cents, that `legacy_orders` is deprecated. Declare that meaning under `truss.annotations`, per table, per column, and as global notes:
```php
// config/truss.php
'annotations' => [
'source' => ['config', 'database'], // first match wins; drop 'database' to ignore DB comments
'notes' => ['All timestamps are UTC.', 'Monetary columns are integer cents unless stated.'],
'tables' => ['orders' => 'One row per order, not per line item.'],
'columns' => ['orders.status' => '0 draft, 1 paid, 2 refunded'],
],
```
Keeping `'database'` in `source` also reads native table and column comments (MySQL, MariaDB, and Postgres; SQLite and SQL Server have none and are skipped). A comment is part of the `CREATE TABLE` definition, not row content, so this stays structure only, the same boundary as a column default. Annotations render into every text format and are removed with `--no-annotations`.
## Compact and focus
```bash
# A dense, token-trimmed plaintext format built for agents
php artisan truss:export --format=llm
# Drop defaults and non-unique indexes to shrink the output
php artisan truss:export --compact
# One table and its foreign-key neighbourhood, out to N hops
php artisan truss:export --focus=orders --depth=1
```
`--compact` drops the detail an agent does not need (column defaults and non-unique indexes) without losing a single table, column, or foreign key. `--focus` narrows the export to one table and the tables it connects to, so you can ground the agent on the slice it is working in. The `llm` format is a bespoke, dense plaintext layout tuned for token budgets; DBML stays the default because it is a standard other tools already read.
## In code: the Truss facade
The same pipeline is available programmatically, so you can build context in your own code, tooling, or tests without shelling out:
```php
use AlbertoArena\Truss\Facades\Truss;
$context = Truss::snapshot()
->only(['orders', 'order_lines'])
->focus('orders', depth: 1)
->compact()
->toDbml();
```
The builder is immutable, so a base builder is safe to share and branch. It offers `only()`, `except()`, `focus()`, `compact()`, `withoutAnnotations()`, `fresh()`, and `connection()`, with a terminal per format: `toDbml()`, `toJson()`, `toCsv()`, `toMarkdown()`, `toMermaid()`, `toLlm()`, and `toArray()`. It produces exactly the same bytes as `truss:export` for the same filters.
`Truss::snapshot()` is built for exports, so it carries no schema diff and no doctor findings. If you are rendering the dashboard's own view rather than exporting, `Truss::payload()` returns the array it draws from, including both. See [Embedding](/guides/embedding/).
## Over HTTP: the gated route
The dashboard download button and any HTTP client share one route, `GET {prefix}/export/{format}`, behind the `viewTruss` gate. It accepts the same filters as query parameters (`only`, `except`, `focus`, `depth`, `compact`, `connection`), so the command, the facade, and the dashboard all produce identical output from one source of truth.
## A schema is not a semantic layer
Truss tells an agent what exists, not what the business means beyond the annotations you write. Two honesty notes worth keeping in mind:
- The structure-only guarantee holds on every surface here (flags, facade, route). No export, in any format or flag combination, ever contains row data.
- If you pair this context with a tool that executes generated SQL, that tool needs its own read-only connection and its own validation. Truss produces context; it never runs a query for you.
## Next
For an agent that queries your schema on demand rather than from a paste, see the [MCP server](/guides/mcp-server/).
---
# Authorization
Source: https://trussphp.com/guides/authorization/
Truss is designed to be safe to install in production and gated there. Access is guarded by two independent layers, and **both** must pass.
:::note
Running Truss on a non-local server requires it to be installed as a regular dependency, not a dev one. A `--dev` install is stripped from `composer install --no-dev` deploys, so `/truss` would 404. See [Installation](/getting-started/installation/).
:::
## The two layers
1. **`truss.enabled`** is the deploy switch. When it is off, the routes respond **404**, as if they do not exist. It defaults to the `local` environment only, so a production deploy stays dark until you set `TRUSS_ENABLED=true`.
2. **The fixed `viewTruss` gate** is the access control. It is consulted **only in non-local environments** (in `local` it is open). A denial returns **404**, not 403, so the dashboard never confirms it exists to someone who may not view it.
`enabled` alone does not grant access, and the gate alone does not make the routes exist. To expose Truss in a non-local environment you must do both: enable it, and authorize the viewers.
:::caution
Both failure modes return 404 on purpose. If you set `TRUSS_ENABLED=true` in production but see 404, check that your user passes the `viewTruss` gate (see below).
:::
## Authorize by email (zero code)
For the common "let these admins in" case, the shipped `viewTruss` gate admits the emails you list. Set them via `TRUSS_ALLOWED_EMAILS` as a comma-separated list:
```dotenv
TRUSS_ENABLED=true
TRUSS_ALLOWED_EMAILS="ada@example.com,grace@example.com"
```
The list is ignored in `local`, and it **fails closed**: an empty list means no one may view in a non-local environment until you add emails or override the gate.
## Authorize by role (override the gate)
For anything beyond an email list, define your own `viewTruss` gate in one of your service providers' `boot()` methods (for example `AppServiceProvider`). Your definition fully replaces the shipped default, and it works with any authorization system, so this is where roles and permissions go:
```php
use Illuminate\Support\Facades\Gate;
Gate::define('viewTruss', fn ($user) => $user->isAdmin());
```
The ability name is fixed (`viewTruss`); only the callback varies, and that lives in your app. Truss registers its default gate only if you have not defined one, and a definition in your app wins regardless of load order.
Once you define your own gate, `TRUSS_ALLOWED_EMAILS` is unused and can be dropped from your environment. Keep `TRUSS_ENABLED`.
### If your application has no Gate at all
Some applications ship their own authentication and never bind Laravel's `Gate` contract. October CMS is one. From **v1.10** Truss handles that: it no longer touches the authorization system while booting, and defines its gate only when a request needs one.
Two consequences, both deliberate. Every `php artisan` command works normally, including `truss:doctor` and `truss:export`, which never consult the gate and are safe in CI. And the dashboard returns **404**, the same answer as a denial, because a route that cannot be authorised should stay invisible rather than confirm it exists.
If you want the dashboard in an application like this, bind a `Gate` implementation and define `viewTruss` on it. Before v1.10, installing Truss in such an application broke every artisan command, so if you are on an older version this is the upgrade that fixes it.
### With Spatie roles or permissions
If you use [spatie/laravel-permission](https://spatie.be/docs/laravel-permission), gate by role, or preferably by a dedicated permission:
```php
// By role
Gate::define('viewTruss', fn ($user) => $user->hasRole('admin'));
// By permission (survives role renames; grant it to whichever roles should see Truss)
Gate::define('viewTruss', fn ($user) => $user->can('view-truss'));
```
Truss stays framework-agnostic on purpose: it does not read roles or permissions itself (no assumption that your users even have them), so a role check lives in your gate, not in Truss's config.
### Gate::before still applies
Any `Gate::before` callback in your app runs ahead of `viewTruss` as well, so an app-wide bypass covers it too. A common pattern is a super-admin bypass:
```php
Gate::before(fn ($user) => $user->hasRole('super-admin') ? true : null);
```
With this, super-admins pass `viewTruss` automatically, whatever its callback.
### Guests
An unauthenticated request resolves to a `null` user, so a non-nullable check such as `fn ($user) => $user->hasRole('admin')` safely denies guests (they get 404). If you would rather send guests to the login page than return 404, add `auth` to `truss.middleware`.
## The auth context (middleware)
The gate can only identify the viewer if the request has already passed through session and auth middleware. The `truss.middleware` config key provides that stack, and it defaults to `['web']`:
```php
// config/truss.php
'middleware' => ['web'],
```
Without it, `$request->user()` would be `null` in production and the gate would deny everyone, including the admins who should have access. If your app authenticates differently (a custom guard, or Sanctum for an SPA), swap the stack here. The `viewTruss` check is always appended after this stack and cannot be configured away.
## Testing your gate
Two things catch people out when writing a test for a custom gate:
1. The test environment is **non-local**, so the gate is consulted (good), but `truss.enabled` defaults to off there. Enable it in the test, or even an authorized user gets 404 from the deploy switch before the gate runs.
2. A denied user gets **404**, not 403.
```php
it('admits an admin and hides Truss from other roles', function () {
config()->set('truss.enabled', true); // test env is non-local, so enable it
$this->actingAs(tap(User::factory()->create())->assignRole('admin'))
->get('/truss')->assertOk();
$this->actingAs(tap(User::factory()->create())->assignRole('operatore'))
->get('/truss')->assertNotFound(); // 404, not 403
});
```
## Summary
| Environment | `enabled` off | `enabled` on |
| --- | --- | --- |
| **local** | 404 | open (the gate is not consulted) |
| **non-local** | 404 | the `viewTruss` gate decides; a denial is 404 |
The production recipe: set `TRUSS_ENABLED=true`, list admin emails in `TRUSS_ALLOWED_EMAILS` (or override the gate), and be logged in as an allowed user.
---
# Embedding
Source: https://trussphp.com/guides/embedding/
Truss renders its own page at `/truss`, and the client asks your application for the schema over HTTP. That is the right design for a standalone dashboard and the wrong one for a page you are building yourself: if you are already inside the application, making an HTTP request to it is a round trip to fetch something you were holding all along.
Two pieces make that unnecessary. `Truss::payload()` hands you the array the dashboard runs on, in PHP. A `
```
When it is there, the diagram is drawn from it and **no schema request is made**. When it is not, the client fetches `data-schema-endpoint` exactly as before. Truss's own dashboard embeds nothing and still fetches, so nothing changes for a normal install.
In Blade, with the view published (`php artisan vendor:publish --tag=truss-views`), that is one line inside the `#truss-app` element:
```blade
```
:::caution
Use `JSON_HEX_TAG`. A column default or a table comment containing `` would otherwise close the block early and break the page. Truss reads comments as structure, so this is not hypothetical.
:::
Everything downstream is untouched. The filter, the focus picker, the depth control and the type-label toggle all still run in the browser against what you handed over, which is the difference between embedding the diagram and rendering a picture of it.
A script block rather than an attribute for two reasons: a schema envelope is large, and `type="application/json"` is never executed, so this stays safe under the same strict Content-Security-Policy the rest of the package is built for.
### It fails in two directions on purpose
- **A partial or empty payload degrades.** The envelope has grown optional keys across releases, so a missing `diff` or `doctor` gives you a working diagram without them rather than a crash. A crash in the loader would leave the page on its loading banner for ever, which tells a reader nothing.
- **A payload that is not valid JSON throws.** It does not quietly fall back to fetching, because on a page with no schema endpoint that fallback would produce a 404 and report *that*, sending you after a routing problem you do not have instead of the typo you do.
## What still goes over HTTP
Embedding removes the schema request. It does not remove the assets: the stylesheet, the JavaScript modules and Mermaid still load from `{prefix}/assets/{file}`.
**Those routes are gated too**, with everything else, so they never confirm Truss exists to someone who may not see it. The practical consequence is worth knowing before you debug it: if your page is visible to a user who does not pass the `viewTruss` gate, the page renders and the assets 404, and you get a blank frame rather than a permission error. Keeping your page's check identical to the dashboard's, as above, makes that impossible by construction.
## What this does not cover yet
The container markup is **not a public contract**. The frontend expects the elements the shipped Blade view provides, and several of them are required rather than optional, so a page that keeps only a canvas will not boot.
What is supported today is the shipped view, or a copy of it, running on a payload you provide instead of on a fetch. Building a page with its own chrome around the Truss canvas is possible but undocumented, and the markup it depends on may change in any release. If that is what you are doing, pin your Truss version, and say so in an issue: it is the case that would justify making the contract explicit.
---
# Focus and filter
Source: https://trussphp.com/guides/focus-and-filter/
Real applications have dozens or hundreds of tables. Rendering all of them at once is possible (Truss raises Mermaid's size guards so large schemas draw rather than error), but it is rarely what you want to read. Focus and filter are how you keep things legible.
## Filter
Type in the **Filter** box to narrow the diagram to tables whose name contains what you typed. It is a plain, case-insensitive substring match on the table name. Clearing the box shows everything again.
## Focus
Pick a table in **Focus** to reduce the diagram to that table plus its foreign-key neighbours. Neighbours are followed in both directions: tables the focused table references, and tables that reference it.
- The focused table is **centred** in the viewport and **highlighted** with an accent border, so you can always find it.
- **Depth** controls how many hops of neighbours are shown. Depth `1` (the default, configurable via `focus.default_depth`) shows immediate neighbours; increase it to pull in neighbours-of-neighbours.
### Searching for a table
From v1.9.0 the Focus control is a **searchable picker**: type part of a table name and the list narrows as you type.
- It matches **anywhere in the name**, case-insensitively, so `item` finds `order_items` as well as `items`. This is the same substring rule the Filter box uses, so the two controls agree.
- Matches are **ranked**: an exact name first, then names that start with what you typed, then names that contain it further in. Typing `item` still puts `items` at the top.
- The part that matched is **marked in each row**, so it is clear why a table is in the list.
- Leaving the box empty lists every table, with **Clear focus** as the first row.
- The match is literal text, not a pattern: a `.` means a dot.
The picker is fully operable from the keyboard. Arrow keys move through the list, **Home** and **End** jump to its ends, **Enter** commits the highlighted table, **Escape** closes the list and puts back the table you had focused, and **Tab** moves on without committing a half-typed name. Screen readers hear the number of matches as you type. See [Accessibility](/guides/accessibility/).
Typing a name that does not exist focuses nothing: the box reverts when you leave it, rather than quietly focusing the wrong table.
Focus and filter **compose**: focus operates on whatever the filter left in, so you can filter to a group of tables and then focus within it.
You can also **click a table name** in the diagram to focus it directly, without using the Focus box. The same menu lets you export that table's structure; see [Exporting a table](/getting-started/quick-start/#exporting-a-table).
## Shareable views
The current connection, filter, focus, and depth are reflected in the URL query string, updated live as you interact. So `/truss?focus=projects` opens straight into that focused view, and you can bookmark or share any view you have set up. Nothing is stored server-side; the query string is the whole state.
## Auto-fit and the readable floor
Whenever the content changes (on load, filter, focus, or connection switch) the diagram auto-fits the viewport. The auto-fit honours a **readable floor** (`diagram.min_zoom`, default `0.7`): a large schema is framed at a legible zoom that you pan around, rather than shrunk to an unreadable speck.
The **Fit** button ignores the floor and frames the whole diagram at once, so the full overview is always one click away.
## Large-schema hint
Above a configurable table count (`large_schema.warn_above`, default `60`), an unfocused, unfiltered diagram shows a hint suggesting you filter or focus. It is only a hint; the diagram still renders.
## Excluded tables
Framework and infrastructure tables (`migrations`, `sessions`, `cache`, `jobs`, and so on) are excluded by default via `excluded_tables`. Exclusions are applied **server-side**, so excluded tables never reach the browser at all. See the [Configuration reference](/reference/configuration/).
## How much of the schema is on screen
The footer reads `24 of 32 tables` whenever the diagram is showing less than the whole connection, and a plain `32 tables` when it is showing all of it. Both halves of this page narrow the view, and so does `excluded_tables`, so the count answers the question a short-looking diagram raises: Truss saw thirty-two and is drawing twenty-four.
Two numbers, never three. A filter or a focus counts against what the browser received, so a filtered diagram reads `3 of 24 tables` rather than stacking the exclusions on top.
## Showing the hidden tables
Where [`reveal_excluded`](/reference/configuration/#reveal_excluded) allows it, a **Show hidden tables** toggle appears in the toolbar and draws the tables `excluded_tables` hides. Toggle it off and they are gone again. It is on by default in local, so you have it while you work, and off elsewhere unless the application asks for it.
Revealed tables are drawn **muted, with a dashed outline**. That is deliberate: the schema diff and the doctor always run on the filtered set, so a revealed table carries no change marks and no health badges, and drawing it like an ordinary table would suggest it had been checked and found clean.
Where revealing is not allowed the toggle does not appear at all, and the excluded tables never reach the browser. The footer still reports how many there are, because a count says the diagram is partial without disclosing what was left out.
---
# Laravel Boost
Source: https://trussphp.com/guides/laravel-boost/
[Laravel Boost](https://github.com/laravel/boost) sets up the AI agents on a Laravel project: it detects which ones you use and writes them a shared set of guidelines and skills. Truss ships its own, inside the package, so an agent configured through Boost knows Truss is installed and reaches for your real schema instead of guessing at columns.
There is no MCP server to wire up by hand on this path, and nothing is added to your dependencies. The shipped files are inert Markdown: if Boost is not installed, nothing reads them and nothing changes.
## Install
Boost finds the content by itself, but it never installs third-party content without asking. Run:
```bash
php artisan boost:install
```
When Boost lists the third-party packages it found, tick **`albertoarena/laravel-truss (guidelines, skills)`**.
:::note
Nothing third-party is preselected, on a first install or with `--no-interaction`. That step is yours, which also means Truss never quietly spends your agent's context without your say-so. Boost remembers the choice, so later `boost:update` runs keep it, and pick up anything Truss adds later without asking twice.
:::
## What you get
**A guideline**, short and always in the agent's context: what Truss is, the commands that ground a task in the real structure, and the boundary that Truss never returns row data. It is deliberately small, because standing context is spent on every request.
**A skill**, longer and loaded only when a task is actually about the database. It teaches the workflow rather than the commands:
1. Read the structure you are about to change, focused on the relevant table and its foreign-key neighbourhood.
2. Check that ground with `truss:doctor` before building on it.
3. Write the migration the way your codebase writes migrations.
4. Confirm what the migration actually did with `truss:diff`, and pay attention when it shows more than you expected.
## Three ways an agent reaches your schema
Boost is a third path in, not a replacement. They are additive, and nobody has to choose only one:
| Path | Best for | Setup |
| --- | --- | --- |
| [MCP server](/guides/mcp-server/) | The richest surface: five tools and a resource, queried on demand, always current | `composer require laravel/mcp`, then point your client at `php artisan mcp:start truss` |
| Laravel Boost | The lowest friction if you already use Boost, across every agent it configures | `php artisan boost:install`, tick Truss |
| [`truss:export`](/guides/schema-export/) | CI, scripts, and any CLI-capable agent; no optional dependency at all | None, it ships with Truss |
:::caution[One thing Boost cannot do]
Boost writes only its own MCP server entries, so it cannot install or register the Truss MCP server for you. Anything claiming otherwise is wrong. The two paths are independent, and the MCP server stays the richer of the two if you want tools queried on demand.
:::
## Turning it off
Truss has no config switch for this, and cannot have one: Boost discovers the files with a filesystem scan over your vendor directory, which never loads Truss's config. A `truss.boost.enabled` setting would silently do nothing.
The opt-out that works is Boost's own, in your `config/boost.php`. Boost does not publish that file, so create it if you have not already. Note the two lists take different kinds of key, a guideline by its full key and a skill by its name:
```php
'guidelines' => [
'exclude' => ['albertoarena/laravel-truss/truss'],
],
'skills' => [
'exclude' => ['truss-schema'],
],
```
## Structure only, as always
Both files are agent-facing instruction, so they state the boundary explicitly and repeat it at the end: Truss exposes tables, columns, types, indexes, and foreign keys, never row contents. An agent reading them is told not to reach for Truss to inspect records. That boundary is covered by tests in the package, alongside a test that asserts Boost still discovers the content, so a change on either side fails the build rather than quietly ending the integration.
---
# MCP server
Source: https://trussphp.com/guides/mcp-server/
Pasting an export is fine, but it goes stale the moment your next migration runs. The Truss MCP server lets an agent that speaks the [Model Context Protocol](https://modelcontextprotocol.io/) (Claude Code, Cursor, and others) query your **current** schema on demand instead. It is read-only and structure only by construction: it answers with structure, it never returns row data, and there is no AI inside Truss.
## Install and start
The server is optional and adds no required dependency. Opt in by installing the first-party `laravel/mcp` package, then start the server:
```bash
composer require laravel/mcp
php artisan mcp:start truss
```
Truss registers the server itself, so there is nothing to add to `routes/ai.php`. It is enabled by default once `laravel/mcp` is installed; set `truss.mcp.enabled` to `false` to turn it off.
:::note
The MCP feature needs Laravel 12.41.1 or newer (or Laravel 13). Because `laravel/mcp` is optional, that is a requirement of the feature, not of Truss itself, whose own minimum is unchanged.
:::
## Connect your client
Point your MCP client at the local command. For Claude Code or Cursor, which run inside your project, add a stdio server to your MCP config with the short form:
```json
{
"mcpServers": {
"truss": { "command": "php", "args": ["artisan", "mcp:start", "truss"] }
}
}
```
### Claude Desktop
Claude Desktop launches the server from its own working directory with a minimal environment, so it does not see your shell's `PATH` or your project folder. Use **absolute paths** for both the PHP binary and `artisan`, and pass any environment the app needs. Edit `claude_desktop_config.json` (Settings, Developer, Edit Config; on macOS it lives at `~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"truss": {
"command": "/absolute/path/to/php",
"args": ["/absolute/path/to/your/app/artisan", "mcp:start", "truss"],
"env": { "APP_ENV": "local" }
}
}
}
```
Find the paths with `which php` and the app's `pwd`, then fully quit and reopen Desktop. The `truss` tools then appear in the tools list. If it does not connect, check `~/Library/Logs/Claude/mcp*.log`; the usual causes are a wrong `php` path or a missing environment variable the app needs to boot.
The tools declare themselves read-only (they advertise the MCP `readOnlyHint`), so a client that surfaces that hint can present them as read-only rather than prompting for write approval on every call.
## Tools and resource
The server exposes five tools and one resource, all read-only and structure only:
| Name | What it returns |
| --- | --- |
| `list_tables` | Every table with a one-line structural summary (column count, whether it has a primary key, foreign-key count) |
| `describe_table` | One table's columns, primary key, indexes, foreign keys, and annotations |
| `get_schema` | The whole structure in any format (`dbml`, `json`, `csv`, `markdown`, `mermaid`, `llm`), optionally compact or limited to some tables |
| `focus_table` | A table and its foreign-key neighbourhood, out to a given depth |
| `get_structural_review` | The deterministic `truss:doctor` findings for the schema |
The resource `truss://schema` is the "read the whole thing" convenience: the entire structure as one compact, annotated document.
Each tool takes an optional `connection` argument and reads the same annotations you configure for the export, so the meaning you declare shows up here too.
## The same guarantees, everywhere
Every tool drives the same engine as the CLI and the facade, so it inherits every safeguard automatically:
- **Structure only, never data.** Proven by the same canary test that guards the exports.
- **Excluded tables stay excluded.** `truss.excluded_tables` is stripped before anything is returned, so an excluded table can never surface through a tool or the resource.
- **Only managed connections.** A connection you have not configured under `truss.connections` is rejected.
The web (HTTP) transport is a planned follow-up; today the server runs over local stdio, which adds no new remote surface and no new authentication question. To build context without a running server, see [Truss as AI context](/guides/ai-context/).
---
# Schema diff
Source: https://trussphp.com/guides/schema-diff/
Schema diff answers a question you actually ask after running a migration: **what did that change?** Truss keeps the schema from before your last migration as a baseline, compares it against the current schema, and shows you the added, removed, and changed tables, columns, indexes, and foreign keys. Structure only, never data.
There are two ways to see it: a **"Changes" panel** in the dashboard, and the **`php artisan truss:diff`** command for the terminal and CI.
## How the baseline works
The `MigrationsEnded` event fires after a migration has run, so the live database is already in its new state. The only remaining record of the previous schema is the snapshot Truss had cached, so that is what becomes the baseline: on each migration, before the cache is refreshed, the currently cached snapshot is saved as the baseline.
The baseline is the one thing Truss writes to disk. It is a structure-only JSON file (never row data) at `truss/baselines/{connection}.json` on the disk set by `diff.disk`, which is `local` by default. It lives on disk rather than in the cache for a simple reason: once a migration has run, the previous schema cannot be rebuilt from the live database, so it must not depend on a cache that a deploy might clear.
Because the baseline comes from the cached snapshot, a baseline is only recorded when a snapshot was already cached before the migration. The first migration on a cold cache records no baseline; the diff appears from the next migration onward.
## Try it
The quickest way to see a real diff, using a throwaway column you roll back afterwards:
```bash
# 1. Cache the current schema. This becomes the baseline on the next migration.
php artisan truss:rebuild
# 2. Create a migration that changes the structure.
php artisan make:migration add_truss_probe_to_users_table
# In up(): $table->string('truss_probe')->nullable();
# In down(): $table->dropColumn('truss_probe');
# 3. Migrate. The pre-migration snapshot is saved as the baseline,
# and the current snapshot is rebuilt with the new column.
php artisan migrate
# 4. See what changed.
php artisan truss:diff
```
`truss:diff` prints the change:
```
Schema changes on mysql since the last migration:
Changed tables:
~ users
column added: truss_probe (varchar(255))
```
Or open the dashboard with `php artisan truss:open`, click the **Changes** button in the toolbar, and the `users` table is tinted with the change listed in the panel. Each added or changed table name in the panel is a link that focuses the diagram on that table.
Roll the probe back when you are done:
```bash
php artisan migrate:rollback
```
The rollback fires `MigrationsEnded` too, so it captures a fresh baseline and rebuilds. A `truss:diff` after the rollback will now show `truss_probe` as **removed**, a second demonstration for free.
## Configuration
Two keys under `diff` in `config/truss.php`:
```php
'diff' => [
'enabled' => (bool) env('TRUSS_DIFF_ENABLED', true),
'disk' => env('TRUSS_DIFF_DISK', 'local'),
],
```
- **`diff.enabled`** (default `true`) is the master switch. When `false`, no baseline is captured, **nothing is written to disk**, the dashboard "Changes" button is hidden, and `truss:diff` reports that the feature is off. The check runs before any filesystem access, so setting it false is a hard guarantee that Truss leaves your disk untouched.
- **`diff.disk`** is the filesystem disk the baseline is written to, `local` by default. The path is always `truss/baselines/{connection}.json`.
This deliberately does **not** follow your application's default disk. The baseline is derived tooling state rather than application data, so a remote default would put dev-tool files in a production bucket, cost money to read on every dashboard load, and let several instances race on one object. Point it somewhere else if you want to, but prefer a local disk.
### When the baseline cannot be read
A disk problem costs you this feature and nothing else. The diagram, the doctor and the exports need no filesystem access, so they carry on: the dashboard shows a notice explaining that "Changes" is unavailable, the API sets `diff_unavailable` on its response, and `truss:diff` names the disk, prints the underlying error, and points at `TRUSS_DIFF_DISK`. A baseline that cannot be written is equally non-fatal, including during a migration.
The baseline file is derived and safe to delete (the diff simply goes empty until the next migration records a new one). It is worth gitignoring alongside `storage/`.
## In production
Nothing runs on a schedule, and `truss:diff` and `truss:rebuild` are never called automatically. The only automatic action is the snapshot rebuild plus baseline capture on `MigrationsEnded`, and it happens **only when Truss is enabled**. If Truss is disabled (the default outside `local`) or installed as a `--dev` dependency, nothing is captured and nothing is written.
When Truss is enabled in a non-local environment, a deploy that runs `php artisan migrate` records the baseline. Note that the baseline is taken from the cached snapshot, so if your deploy clears the cache before migrating, that run records no new baseline (the last baseline file stays in place). For reliable per-deploy diffs, use a persistent cache store and migrate before rebuilding the cache. To keep the gated dashboard but guarantee no writes to disk, set `TRUSS_DIFF_ENABLED=false`.
## Caveats
- **Renames read as a remove plus an add.** Tables, columns, indexes, and foreign keys are matched by name, so there is no rename detection.
- **One baseline per connection.** The diff is always "since the last recorded migration", not a full history.
- **Removed tables are list-only.** They no longer exist in the current diagram, so the panel lists them rather than drawing them.
- **Structure only.** Both sides of the comparison are structure-only snapshots, so the diff can never expose row data.
---
# Schema doctor
Source: https://trussphp.com/guides/schema-doctor/
The schema doctor reviews your database **structure** for problems you can spot without ever reading a row: a table with no primary key, a foreign key with no index, duplicate or redundant indexes, a foreign key whose type does not match the key it references, money stored as a float, and more. It is deterministic and structure-only, with no AI and no network call, so it is safe to run in CI.
There are two front ends for one engine: the **`php artisan truss:doctor`** command in the terminal and CI, and the **Health** panel in the dashboard (the heart icon in the toolbar). The command is called "doctor" and the panel is labelled "Health", but they are the **same feature** running the same rules over the same snapshot, so they always report the same findings.
## The command
```bash
# Review the default connection
php artisan truss:doctor
# A specific connection, as JSON for tooling
php artisan truss:doctor --connection=mysql --format=json
# Be stricter, and fail on warnings too
php artisan truss:doctor --preset=strict --fail-on=warning
```
`truss:doctor` (aliased `truss:check`) prints the findings as a table grouped by table, then exits:
- `0` when clean (nothing at or above the fail level),
- `1` when a finding is at or above `--fail-on` (default `error`),
- `2` on a bad option, a connection problem, or a missing snapshot.
So a migration that introduces a problem can fail the build. Point a commit hook or a CI step at it and a new unindexed foreign key stops being something you find in production.
## Presets and confidence
Every rule declares a **confidence** (high or heuristic) that is kept separate from its severity. High-confidence rules are certain from the structure alone (a table genuinely has no primary key). Heuristic rules are name-based guesses (a `*_id` column that looks like a foreign key but has none, a column called `price` stored as a float): often right, sometimes a deliberate choice.
- `recommended` (the default) runs **high-confidence rules only**, so the first run is quiet and trustworthy. A heuristic finding still fires at its real severity when you turn it on: confidence and severity are independent.
- `strict` runs **everything**, heuristics included.
- `none` runs nothing but the rules you explicitly enable.
Every rule can be enabled, disabled, re-severitied, or ignored per pattern in config, so you tune the signal to your project.
## Engine awareness
A rule can adapt to the database driver. The clearest case is a foreign key with no index: on PostgreSQL and SQLite, which do not auto-index foreign keys, it is an **error**; on MySQL and MariaDB, which do, it is only **info**. You get the right severity for the database you actually run.
## In the dashboard
The dashboard shows the same findings the command does, under the name **Health** (there is no "doctor" button; the heart icon is it). Open the dashboard (`php artisan truss:open`) and the heart button in the toolbar opens a **Health** panel that lists them grouped by table, with the heuristic ones marked as such. Click a table to focus the diagram on it; click the button again to close.
You do not have to open the panel to know something is wrong: every table with a finding carries a small severity badge on the diagram, and the offending **column** is marked on the table itself. Click a marked column to read the finding for that field. The panel can be maximized when a report gets long.
Two switches under `doctor` in `config/truss.php` control the dashboard side:
```php
'doctor' => [
// ...preset, rules, ignore, fail_on, exclude...
'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true),
'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true),
],
```
- **`dashboard`** (default `true`) shows the Health panel at all. When `false`, the dashboard receives no findings and the panel never appears; the CLI is untouched.
- **`flag_tables`** (default `true`) badges tables with findings on the diagram even when the panel is closed. Set it `false` to keep the diagram clean and surface findings only when you open the panel.
The report rides the schema endpoint the diagram already loads, so there is no extra request, and it is gated by the same `viewTruss` authorization as the rest of the dashboard.
Try it on the **[live demo](/demo/)**: open the Health panel and you will see a handful of foreign keys flagged for a missing index, and `users.email` flagged (heuristically) for having no unique constraint.
## Configuration
Everything lives under `doctor` in `config/truss.php`:
```php
'doctor' => [
'preset' => env('TRUSS_DOCTOR_PRESET', 'recommended'), // recommended | strict | none
'rules' => [
// 'TRUSS-INT-002' => true, // turn a heuristic rule on
// 'TRUSS-IDX-001' => ['severity' => 'error'], // change a severity
],
'ignore' => [
// 'TRUSS-IDX-001' => ['audit_log.actor_id'], // silence a known case (fnmatch)
],
'fail_on' => env('TRUSS_DOCTOR_FAIL_ON', 'error'), // error | warning | info | never
'exclude' => [], // extra tables, on top of excluded_tables
'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true),
'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true),
],
```
See the [configuration reference](/reference/configuration/) for the full key list.
## Rules
Every finding carries a stable **code** (e.g. `TRUSS-INT-003`), shown in both the command output and the Health panel, so you can look it up here, silence it, or change its severity in config. There are fourteen rules across three categories. Heuristic rules are name-based guesses and are **off under `recommended`** (on under `strict`).
| Code | Checks | Severity | Confidence |
| --- | --- | --- | --- |
| `TRUSS-INT-001` | Table has no primary key | error | high |
| `TRUSS-INT-002` | A `*_id` column matches a table but declares no foreign key, polymorphic columns excluded | warning | heuristic |
| `TRUSS-INT-003` | A foreign key column's type differs from the key it references | error | high |
| `TRUSS-INT-007` | A pivot table has no unique constraint on its key pair | warning | high |
| `TRUSS-INT-009` | A polymorphic pair (`*_type`, `*_id`) has no composite index | warning | heuristic |
| `TRUSS-INT-010` | A foreign key references one table while a table named after its column exists and is a different one | error | high |
| `TRUSS-IDX-001` | A foreign key column has no supporting index | error \* | high |
| `TRUSS-IDX-002` | Two indexes cover the same columns | warning | high |
| `TRUSS-IDX-003` | An index is a left prefix of another, so it is redundant | warning | high |
| `TRUSS-IDX-004` | An index duplicates the primary key | warning | high |
| `TRUSS-IDX-005` | A `deleted_at` column is not indexed | warning | heuristic |
| `TRUSS-IDX-006` | An `email` / `slug` / `uuid` / `token` column has no unique constraint | warning | heuristic |
| `TRUSS-TYP-001` | A money-looking column is stored as `float` or `double` | error | heuristic |
| `TRUSS-TYP-002` | An `is_*` / `has_*` column is stored as `varchar` | warning | heuristic |
\* `TRUSS-IDX-001` is engine-aware: an **error** on PostgreSQL and SQLite, which do not auto-index foreign keys, and only **info** on MySQL and MariaDB, which do.
### What `TRUSS-INT-007` counts as a pivot
Two single-column foreign keys are necessary but **not sufficient**, or every entity table with two relationships would be flagged. A profile table with a `user_id` and a `plan_id` is not a many-to-many join, and the composite unique key the rule suggests would be wrong there.
So Truss also asks whether the table carries its key pair and little else. It counts the columns that are not the pair, not `id`, and not `created_at` / `updated_at` / `deleted_at`. A table whose primary key **is** the pair, or which has no primary key at all, may carry one such column. A table with its own `id` primary key must carry none, because a table with its own identity is treated as an entity unless it has nothing else in it. Any other primary key is never a pivot.
The rule also stays quiet when a unique index already covers **any part** of the pair, since a unique `user_id` makes `(user_id, plan_id)` unique on its own. Nullable columns do not count, because most engines allow repeated `NULL`s in a unique index.
**A genuine pivot carrying two payload columns is missed on purpose.** A finding you never see costs you nothing, and a wrong one here recommends a migration that loses rows.
### Why `TRUSS-INT-010` ignores most name mismatches
An `author_id` column referencing `users` is not a mistake, it is an alias, and aliases are ordinary. So the rule does not ask whether the column name and the referenced table differ. It asks whether **the name names a table that actually exists, and the key points somewhere else**.
`author_id` survives because there is no `authors` table for its name to name. `cart_line_id` is flagged because `cart_lines` does exist and the key references `carts` instead, which means the constraint validates against the wrong parent: an insert only succeeds when the id happens to exist in both tables, and `ON DELETE CASCADE` fires when the wrong row is deleted.
That distinction is the whole rule. On Lunar 1.5.0, six of the seven name mismatches among 83 foreign keys were deliberate aliases, so a rule that flagged every mismatch would have been wrong 86% of the time.
Three further cases produce silence rather than a guess:
- **Ambiguity.** A candidate name resolves plural first, then singular, and only when exactly one table matches. More than one match reports nothing.
- **Morph targets.** A `*_id` with a sibling `*_type` column points at whichever table the type names, so its own name is not a claim about one table. `TRUSS-INT-009` owns that shape.
- **Composite keys.** A multi-column key is not named after one table, so there is no name to test.
A prefixed schema is matched through the prefix the referenced table already carries, so `lunar_cart_lines` is found for a key that references `lunar_carts`, and a table belonging to a different prefix is never treated as the match.
Use these codes in `doctor.rules` (to enable, disable, or re-severity a rule) and `doctor.ignore` (to silence a known case per table or `table.column`). More rules land in later phases.
## Caveats
- **Structure only.** The doctor reads the same cached snapshot the diagram uses and never queries row data. It cannot know selectivity or query patterns, so it reviews structure, not performance.
- **Heuristics are guesses.** They are off by default for a reason. Turn them on when you want them, and ignore the deliberate exceptions per pattern.
- **Deterministic, for a given version.** No AI, no network. The same schema and the same version of Truss always yield the same findings, so a diff in CI output is a real structural change. Across an upgrade it may not be: a rule that gets narrower reports less on a schema nobody touched. See [Upgrading](#upgrading-and-a-changed-rule) below.
## Upgrading, and a changed rule
Rules are calibrated against real schemas, so a release can change what the doctor reports on a database nobody touched. The first run after an upgrade is the one to read carefully: a smaller report is usually Truss having got quieter, not your schema having improved.
**v1.11 added `TRUSS-INT-010`.** It is high confidence and an `error`, so it runs under the default `recommended` preset, and a new rule can only add findings. A CI job on `--fail-on=error` can therefore go red on a schema nobody has touched. Read the finding before silencing it: the rule fires only where a foreign key points at one table while a table named after its column exists and is a different one, which is usually a `constrained()` call that named the wrong table years ago and has been quietly validating against the wrong parent ever since.
**v1.10 narrowed `TRUSS-INT-007`.** Two single-column foreign keys stopped being enough to call a table a pivot. Measured across the twelve real Laravel applications that could run both versions, the rule fired 69 times before and 14 times after, and 56 of those 69 were wrong: profile tables, settings tables and one-to-one joins that are not many-to-many at all. One of them recommended a unique constraint that would have capped a user at a single budget. Those twelve schemas are also the ones the new rule was tuned on, so read the counts as a measure of what changed, not as a forecast of what your own report will do.
What that means for you:
- **A CI job on `--fail-on=error` may now pass where it failed**, on a schema you have not changed. That is the intended outcome, not a regression.
- **Findings you had suppressed in `doctor.ignore` may no longer fire.** A stale ignore entry is harmless, but it is worth pruning so the next real finding is not silently covered by it.
- **Some genuine pivots are now missed on purpose.** A pivot carrying two payload columns escapes the rule. A finding you never see costs you nothing; a wrong one recommends a migration that loses rows.
The same release stopped `TRUSS-INT-002` asking for a foreign key on polymorphic columns, which contradicted `TRUSS-INT-009` inside the same report, so a morph-heavy schema also gets a shorter report than it did.
Findings and counts are only comparable between runs of the **same** version. If you track them over time, record the Truss version alongside the number.
---
# Schema export
Source: https://trussphp.com/guides/schema-export/
`php artisan truss:export` writes your database **structure** to a standard file format, from the command line, so it is not just a button in the dashboard. A CI job can commit an up-to-date schema file and fail the build when it drifts, a script or tool can read the snapshot, and you can pipe the output anywhere. It is the command-line counterpart to the dashboard export, generated from PHP, and like everything in Truss it is structure only, with no network call, so it is safe in CI and commit hooks.
## Formats
One flag, seven formats. Pick the one that suits the job:
| `--format` | Best for |
| --- | --- |
| `dbml` (default) | Version control and diagrams: opens in dbdiagram.io and other DBML tools |
| `json` | Feeding your own tooling: the whole schema as an array of tables |
| `csv` | Spreadsheets: one row per column, with a `table` column |
| `markdown` | A data dictionary to paste into a README or wiki |
| `mermaid` | An `erDiagram` block to embed in Markdown docs |
| `llm` | Feeding a coding agent: a dense, token-tuned plaintext layout |
| `html` | A person: the whole diagram as one file that opens with no server, see [below](#the-whole-diagram-as-one-file) |
## The command
Output goes to **stdout by default** so it pipes cleanly; `--output` writes a file instead.
```bash
# DBML to stdout
php artisan truss:export
# A specific format to a file
php artisan truss:export --format=json --output=docs/schema.json
# Only some tables (config excluded_tables still apply)
php artisan truss:export --tables=orders,order_lines
# A specific connection, skipping a table
php artisan truss:export --connection=mysql --exclude=telemetry
```
Options: `--format`, `--connection`, `--tables` (allowlist), `--exclude` (blocklist, applied after `--tables`), `--output` (a file path; stdout otherwise, and required for `html`), `--check` (see below), `--fresh` (rebuild the cached snapshot first), and `--mermaid` (`html` only: `inline` or `cdn`). For feeding an agent, `--compact`, `--focus`/`--depth`, and `--no-annotations` are covered in [Truss as AI context](/guides/ai-context/).
Config `excluded_tables` always wins over `--tables`, so you cannot filter your way to an excluded table and the export never exposes structure the dashboard hides.
## The whole diagram as one file
`--format=html` is the odd one out. The other six render your tables as text for a tool to read; this one writes the dashboard itself as a single file: the diagram, the filter, the focus picker, the health panel, zoom and pan. It opens by double-clicking it. No server, no network, no database, and whoever opens it does not need Truss, PHP, or access to your application.
```bash
php artisan truss:export --format=html --output=schema.html
```
That is the schema review that used to need a screen share. Attach it to a pull request, send it to a contractor on their first day, or keep one per release so you can open last quarter's structure next to today's.
### What is in the file
Everything it needs, which is why it is around 3.6 MB: your structure, the stylesheet, the fonts, and a copy of Mermaid. The diagram is drawn in the browser from an embedded snapshot, exactly as the dashboard draws it, because it is the same code.
The [structure health](/guides/schema-doctor/) findings ride along too, so a reviewer sees the same flags you do. One consequence worth knowing: a Truss upgrade that changes a doctor rule changes the file, so a committed export can report drift under `--check` on an upgrade rather than on a schema change.
### A smaller file, at a price
`--mermaid=cdn` leaves Mermaid out and loads it from a URL instead, which takes the file from megabytes to kilobytes:
```bash
php artisan truss:export --format=html --mermaid=cdn --output=schema.html
```
The file then needs a network to open, and it will change when the CDN's version moves. It uses `truss.diagram.mermaid_url` when you have set one, so an install that already self-hosts Mermaid points at its own copy. Use it when size matters more than the guarantee; keep the default when you want a file that still works in five years with the wifi off.
### `--output` is required
Unlike the other six formats, `html` refuses to write to stdout:
```bash
php artisan truss:export --format=html
# --format=html requires --output: the file is around 3.6 MB and is not meant for a terminal.
```
Several megabytes of minified JavaScript is not something to discover in a terminal or a CI log, and there is no undo once it has scrolled past.
### Before you commit one
The file is structure only, like every format. It is still worth deciding deliberately: it contains your table names, column names, types, defaults, and comments, so committing one puts your schema into a pull request diff where it outlives the branch. Often that is exactly the point. Just do it on purpose.
`--check` works with it. The embedded snapshot deliberately leaves out the generated-at timestamp and the diff against your baseline, so a committed file does not report drift on a day when nothing about your schema changed.
## Deterministic by design
The output is deterministic: the same schema always produces the same bytes, whatever order the database happens to report its tables in. Tables are sorted alphabetically, foreign keys by source column, indexes by name, and nothing time, version, or host dependent enters the file. That is what makes the drift check below reliable, and it means a committed export file only changes when the schema really does.
## Fail CI on drift with `--check`
`--check` regenerates the export, compares it against the file at `--output`, writes nothing, and exits non-zero when they differ. Commit the export file, then add one CI step:
```bash
# Fail the build if the committed schema file is out of date
php artisan truss:export --format=dbml --output=docs/schema.dbml --check
```
Now a migration that changes the structure without refreshing the committed file fails the build, the same way `truss:doctor` fails on a structural problem. `--check` needs `--output`, since there is nothing to compare against otherwise.
## Exit codes
- `0`: written, or (with `--check`) the file is up to date.
- `1`: `--check` found drift. The one actionable non-error signal, so CI can tell "the file is stale" from "the command broke".
- `2`: a usage or runtime error (unknown format, an unmanaged or unreachable connection, an unwritable path, `--check` without `--output`, `--format=html` without `--output`, an unknown `--mermaid` value, or no tables matched the filters).
## Committing a schema file
A common setup is a `schema.dbml` in the repo, refreshed by the person who runs the migration and enforced by CI:
```bash
# Regenerate after a migration
php artisan truss:export --format=dbml --output=docs/schema.dbml
```
```yaml
# In CI, alongside your test step
- run: php artisan truss:export --format=dbml --output=docs/schema.dbml --check
```
The file becomes a reviewable record of every structural change, and the check keeps it honest. Structure only: the export reads the same cached snapshot the diagram uses and never queries a row.
## Feeding an AI agent
The same export doubles as grounding context for a coding agent. Annotate the structure with meaning, trim it with `--compact`, narrow it with `--focus`, and reach for the `llm` format, or build it in code with the `Truss` facade. See [Truss as AI context](/guides/ai-context/). To let an agent query the schema on demand rather than from a paste, see the [MCP server](/guides/mcp-server/).
---
# Theming
Source: https://trussphp.com/guides/theming/
Truss ships a light and dark "blueprint" theme. To make the dashboard match the app it is embedded in, redefine its colours and fonts from config under `truss.theme`. Everything is optional: you set a few semantic knobs and the rest stay on the default, so a handful of values re-skins the whole dashboard, chrome and diagram alike, in both light and dark.
:::note[Using the Filament plugin?]
This page is about the Truss dashboard at `/truss`. The **Database schema** page inside a Filament panel does not read `truss.theme`: it follows the panel's own palette instead, with nothing to configure. See [Filament panel theming](/filament/theming/).
:::
:::tip[Prefer to design it visually?]
Use the [theme builder](/theme-builder/) to dial in colours and fonts against a live dashboard preview, then copy a ready-to-paste config block. What you build there maps to exactly the `truss.theme` knobs described below.
:::
## Publish the config first
A default install ships no `config/truss.php` (Truss runs on its bundled defaults), so before you can set a theme, publish the config:
```bash
php artisan vendor:publish --tag=truss-config
```
Then edit the `theme` block:
```php
// config/truss.php
'theme' => [
'fonts' => [
'sans' => 'Inter, system-ui, sans-serif',
],
'colors' => [
'light' => [
'accent' => '#3730a3',
'background' => '#ffffff',
],
'dark' => [
'accent' => '#a5b4fc',
'background' => '#0b1020',
],
],
],
```
## The colour knobs
You set semantic roles, not raw internal variables. Each knob drives the tokens it is responsible for, so a couple of values stay coherent:
| Knob | Paints |
| --- | --- |
| `accent` | Primary accent: headings, primary-key badges, entity borders, the focus ring |
| `accent-secondary` | Secondary accent |
| `background` | The canvas and page background, and the relationship-label backdrop |
| `surface` | Panels, table bodies, rows, and inputs |
| `surface-alt` | Row striping |
| `text` | Body and diagram text |
| `muted` | Secondary text, and the relationship lines and labels |
| `border` | Table, panel, and field lines |
Two tokens are **derived** rather than painted with a knob value directly, so a
themed diagram keeps its visual hierarchy instead of going flat: the hairlines
between table rows are a translucent tint of `border`, and the faint background
grid is a tint of `accent`. That way a strong `border` still gives you a strong
table outline with quieter row separators, the same relationship the shipped
palette has. Both derivations need colour channels, so they apply when the knob
is a hex value; an `rgb()`, `hsl()` or keyword value is used as-is.
Set a knob under both `light` and `dark` to theme both modes, or omit `dark` to theme light only. Anything you do not set keeps the shipped default, so a brand match is usually five to eight values, not a full repaint. The light and dark toggle keeps working across a custom palette.
Colours accept hex (`#3730a3`), `rgb()` / `rgba()` / `hsl()` / `hsla()`, or a CSS colour keyword (`rebeccapurple`, `transparent`).
## Fonts
`fonts.mono` and `fonts.sans` take **font-family names only**, for example `'JetBrains Mono, monospace'`. Name a font your app already loads or a system font; Truss does not serve font files for a custom theme (it self-hosts only its own IBM Plex Mono). If you want a bespoke font, load it in your app and name it here. The `mono` family also drives the diagram text.
You can set the two font families from the environment instead of the config file with `TRUSS_THEME_FONT_MONO` and `TRUSS_THEME_FONT_SANS`.
## How it is delivered, and why it is CSP safe
The overrides are served as a normal, same-origin stylesheet from a gated route, linked after the base sheet, and **only when a theme is configured**, so a default install makes no extra request. Because it is a served stylesheet and not an inline `