kdr-dev / dissect
Visualise your Eloquent models and their relationships as an interactive graph. Keep track of what route is exposed and how it's requests & responses are build.
Requires
- php: ^8.2
- illuminate/database: ^11.33|^12.0|^13.0
- illuminate/routing: ^11.33|^12.0|^13.0
- illuminate/support: ^11.33|^12.0|^13.0
- nikic/php-parser: ^5.0
Requires (Dev)
- orchestra/testbench: ^9.5|^10.0|^11.0
README
Visualise your Eloquent models and their relationships as an interactive graph.
Point it at your models directory and open a page: every model becomes a node with its columns, every relation becomes an edge. Drag nodes into an arrangement that makes sense, and the layout is saved to a file you can commit so your team sees the same picture.
- ๐ฆ No build step in your application โ the compiled frontend ships with the package.
- ๐ No
vendor:publishrequired to get running. - ๐ Local-only by default; the viewer exposes your full schema, so it stays off in production unless you switch it on deliberately.
- ๐๏ธ Save views โ named subsets of the graph โ so a schema too big to read at once can be read one bounded context at a time.
- ๐ Click any card to expand it: every column, with its key, unique, nullable, guarded, hidden and cast flags.
- ๐ฃ๏ธ Switch to Routes for your HTTP surface โ every endpoint, what it accepts, what it returns, and which models each one touches.
๐ Quick start
Not on Packagist, so install it straight from GitHub. From your application's root:
composer config repositories.dissect vcs https://github.com/kadirtikil/dissect.git composer require kdr-dev/dissect --dev
Then visit /dissect in your local environment. That's it โ the service
provider is auto-discovered, and there is nothing to publish or build. ๐
๐ก Composer resolves packages by tag. To track the unreleased tip instead โ or if no tag has been pushed yet โ ask for the branch:
composer require kdr-dev/dissect:dev-main --dev.๐ SSH works too if you prefer it โ swap the URL for
git@github.com:kadirtikil/dissect.git.
โ Requirements
- PHP 8.2+
- Laravel 11.33+, 12.x, or 13.x
Laravel 11.33 is a hard floor: column and relation discovery is delegated to
the framework's own ModelInspector (the class behind artisan model:show),
which does not exist in earlier releases.
๐ฆ Installation
The quick start above uses composer config, which writes the repository entry
for you. To do it by hand instead, add this to your application's
composer.json:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/kadirtikil/dissect.git" }
]
}
Then:
composer require kdr-dev/dissect --dev
Publishing the config file is optional:
php artisan vendor:publish --tag=dissect-config
โ๏ธ Configuration
Everything is configurable through environment variables, so the published config file is usually unnecessary.
| Variable | Default | Purpose |
|---|---|---|
DISSECT_ENABLED |
unset | Unset means "on in local only". Set to true/false to decide explicitly. |
DISSECT_PATH |
dissect |
URL prefix the viewer is served from. |
DISSECT_MODELS_PATH |
app/Models |
Directory scanned for models. Relative paths resolve from the base path; absolute paths are used as given. |
DISSECT_MODELS_NAMESPACE |
inferred | Set this when your namespace does not follow the conventional app/Models โ App\Models mapping. |
Three settings are config-file only:
middlewareโ defaults to['web']. Add your auth middleware here if you enable the viewer outside local.layout_pathโ defaults tobase_path('.dissect/layout.json'). Deliberately not instorage/(gitignored) orvendor/(wiped bycomposer install), because the layout is meant to be committed and shared.views_pathโ defaults tobase_path('.dissect/views.json'), for the same reason: "the billing models" is worth agreeing on once and reviewing in a pull request.routes.watch_pathsโ defaults to['app', 'routes']. Which directories are watched to notice that the endpoint list has gone stale. Narrow it to the directories that actually hold HTTP code if yourapp/is large.
๐ Models outside app/Models
For a domain-oriented or monorepo layout, set both the path and the namespace:
DISSECT_MODELS_PATH=src/Domain DISSECT_MODELS_NAMESPACE="Acme\\Domain"
๐ What actually keeps this off your production boxes
Worth being precise, because it is easy to assume more protection than there is. The provider is auto-discovered and boots on every request in every environment where the package is installed. One thing decides whether the routes register:
config('dissect.enabled') === null ? app()->environment('local') // the default : (bool) config('dissect.enabled');
So the gate is APP_ENV. Two things have to hold, and dissect controls
neither of them:
- Production deploys with
composer install --no-dev, so a--devinstall is genuinely absent. This is your deploy command, not something the package can enforce. APP_ENVis notlocalthere, andDISSECT_ENABLEDwas not left switched on after somebody debugged something.
Break either โ a staging box left at APP_ENV=local, a deploy that installs dev
dependencies โ and the viewer is live. Unauthenticated, it serves your full
schema, every route, every validation rule, and accepts two POSTs that write
into your project root.
If you enable it anywhere but your own machine, the middleware is the control that holds when the environment check does not:
'enabled' => true, 'middleware' => ['web', 'auth', 'can:view-schema'],
๐งท Upgrades and your files
.dissect/layout.json and .dissect/views.json are yours, and they live in
your project rather than vendor/, so composer update cannot touch them.
Both files record the format version they were written in, and dissect will not
rewrite a file written by a newer version than the one you are running โ it
reads it, renders what it understands, and refuses the save with a message
rather than quietly replacing your layout with the subset it recognised. If a
future release does change the format, your file is copied to
.dissect/layout.json.v1.bak before the first migrating write. An unparseable
file is copied to .dissect/layout.json.corrupt.bak before being replaced.
Nothing here needs an upgrade step from you. It is worth knowing the refusal exists, so that "layout not saved" on a machine running an older dissect reads as a version mismatch rather than a bug.
๐๏ธ Views
A large schema is easier to read a slice at a time. Pick the models you want โ
shift-drag the canvas, or tick them in the View menu's model list โ name the
selection, and it is saved to .dissect/views.json as a named view you can
switch to whenever you like.
- โ Tick a model in the list to add it to the open view, untick it to take it back out. Changes save as you go.
- ๐งญ A model keeps the same position in every view, so switching hides models rather than rearranging the board.
- ๐ค The file is plain JSON and meant to be committed, so your team gets the same views you do. Which one you have open is remembered per browser, not in the file.
๐ฃ๏ธ Routes
The Routes tab is the other half of the same question. The graph says what your data looks like; this says how you reach it โ and, crucially, joins the two: every endpoint lists the models it touches, and every field says which column it came from.
POST api/invoices Request StoreInvoiceRequest
auth:sanctum ยท throttle:60,1 customer_id integer req Customer.id
lines[].sku string req
Response resource ยท 201 Touches
id Invoice.id Customer โ Invoice โ InvoiceLine โ
customer object sometimes Customer
lines[] array Comment
- ๐ Click a model in Touches to jump to it on the graph, ringed and centred. Expand a model card and click Endpoints to go back the other way.
- ๐ Filter by path, route name, controller or verb; facet by method and by whose code it is (
app/vendor/framework). Nothing is hidden from the export โ the facets narrow it. - ๐๏ธ With a saved view open, the endpoint list narrows to the models in it.
- ๐ข
routes.jsonis fetched when you first open the tab, not inlined โ so the graph still boots with no round trips.
๐ฏ Where the shapes come from
Requests are read from a FormRequest's rules() where one is type-hinted, and
from an inline $request->validate([โฆ]) otherwise. Responses are read from the
return type and the toArray() of whatever JsonResource it names, nested
resources included.
Nothing in Laravel can be asked what an endpoint returns, so some of this is read from your source rather than run. Each shape says which:
| Confidence | Meaning |
|---|---|
certain |
The framework itself produced it โ rules() ran, or the resource declared its model with @mixin. |
inferred |
Read from the source. Usually rules() could not run outside a request, or PostResource was assumed to describe a Post. |
unknown |
The class was found but its shape could not be read. Treat it as incomplete. |
A confidently wrong API description is worse than none, so this is shown rather than smoothed over.
๐ How it works
- Schema is built by
SchemaExporterfrom Laravel'sModelInspectorand cached against a fingerprint of your models directory, so the reflection and schema queries run once per model-file change rather than once per request. - Routes are read from the router itself; the request and response shapes behind them are read from your source with nikic/php-parser, never by executing it. Cached against its own fingerprint, and only computed once you open the tab.
- The page inlines schema, layout and views into the initial HTML, so it makes no XHR on boot.
- Live updates work by polling that fingerprint โ edit a model, and the graph refreshes without a file watcher. Edit a controller or a form request, and the endpoint list does the same.
- Assets are served straight from the package's
dist/directory by a route with an allow-list of two filenames. Nothing to publish, nothing to re-publish after an upgrade.
See ARCHITECTURE.md for the full design.
๐ ๏ธ Contributing
The package develops against Orchestra Testbench,
with a Workbench application in workbench/.
composer install pnpm install composer serve # build the workbench app and serve it composer test # PHPUnit pnpm test:unit # Vitest pnpm test:e2e # Playwright pnpm build # compile the frontend into dist/ composer serve:huge # serve a generated 122-model schema instead composer huge:clean # and remove it again
The fixture in workbench/app/Models is small on purpose โ it covers every
relation family and column kind, and the tests assert against it. ๐
workbench/app/Http and workbench/routes do the same job for the endpoint
list: a form request whose rules() cannot be run, a resource with no model
behind it, a closure route, an invokable controller.
composer serve:huge generates the opposite fixture, a schema large enough to
show what layout, the minimap and saved views do under load.
โก Frontend hot reload
composer serve:hmr in one terminal and pnpm dev in another runs the real
Laravel host with the frontend served from Vite, so frontend edits hot-reload
while the PHP side stays genuine.
๐ท๏ธ Releasing
dist/ is committed on purpose โ it is the compiled bundle the Composer
package serves, and the whole promise is one composer require with no build
step in the host application. So every release must rebuild it:
pnpm build git add dist && git commit -m "build: dist for vX.Y.Z" git tag vX.Y.Z && git push --tags
โ ๏ธ A tag that ships a stale dist/ ships a stale UI to everyone who installs it.
๐ License
MIT. See LICENSE.