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.

Maintainers

Package info

github.com/kadirtikil/dissect

pkg:composer/kdr-dev/dissect

Transparency log

Statistics

Installs: 35

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 2

v0.3.1 2026-08-13 15:56 UTC

This package is auto-updated.

Last update: 2026-08-16 17:21:23 UTC


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:publish required 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 to base_path('.dissect/layout.json'). Deliberately not in storage/ (gitignored) or vendor/ (wiped by composer install), because the layout is meant to be committed and shared.
  • views_path โ€” defaults to base_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 your app/ 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:

  1. Production deploys with composer install --no-dev, so a --dev install is genuinely absent. This is your deploy command, not something the package can enforce.
  2. APP_ENV is not local there, and DISSECT_ENABLED was 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.json is 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 SchemaExporter from Laravel's ModelInspector and 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.