Package Discovery

On this page 9

A package can bring its own routes, views, models and migrations into an application, the way a Laravel package does. Install it and its pieces are found:

bun add loghq

No registration step, no copying files by hand. Discovery scans for installed packages that declare a stacks key, records where each one lives, and the framework's own loaders read that record.

Declaring a package

A package opts in by adding a stacks key to its package.json:

{
  "name": "loghq",
  "version": "1.0.0",
  "stacks": {
    "routes": ["routes/api.ts"],
    "views": ["resources/views"],
    "migrations": ["database/migrations"],
    "routePrefix": "loghq",
    "routeMiddleware": ["auth"]
  }
}

Every path is relative to the package's own root. A path that escapes it, by a leading slash or a .. segment, is ignored: a package registering a directory outside itself would serve files the application never installed.

What each field does

These are the fields the framework reads today.

FieldTypeWhat it does
routesstring[]Route files to register. Resolved against the package root.
routePrefixstringPrefix applied to every route the package registers.
routeMiddlewarestring | string[]Middleware applied to every one of those routes.
viewsstring | string[]Template directories. Appended after the application's own, so nothing that already resolves changes.
migrationsstring | string[]SQL migration directories. Defaults to database/migrations.
componentsstring | string[]stx component directories. No default - must be declared.
jobsstring | string[]Background job directories. Defaults to app/Jobs.
namestringStack extension name, for a package that provides whole top-level directories.
descriptionstringStack extension description.
directoriesstring[]Which top-level directories a stack extension provides.

Models have no field. A package that ships app/Models is found without declaring anything, because that is where every Stacks application puts them. Jobs work the same way through app/Jobs, and an application's own job of the same name keeps the name.

views and migrations have defaults too, so a package that only ships views, migrations and models needs no more than "stacks": {}. Declaring them explicitly is for a package that ships more than one subtree, or uses different paths.

routes has no default and must be listed. There is no conventional location to fall back to, and registering every .ts file under a package's routes/ directory would register whatever a package happened to leave there.

Components are opt-in

Every other surface here has a conventional default. components deliberately does not, and a package contributes none unless it declares them:

{ "stacks": { "components": ["resources/components"] } }

The others are namespaced at the point of use. A view is reached by its path, a model by its name, a migration by its filename, so a directory picked up by convention stays inert until something asks for it. A component is reached by bare tag name across every template the process renders, so implying resources/components would enrol any package that happens to have that directory into global tag resolution.

Package components are additive. The framework's own components are searched first, so a package answers for a tag nothing else defines and cannot replace one the framework already provides.

Declared but not yet read

PackageStacksMeta also accepts providers, commands and middleware. Nothing consumes them yet. They are reserved rather than functional, and a package that sets one today gets no behaviour from it.

The manifest

Discovery writes storage/framework/discovered-packages.json:

{
  "generated_at": "2026-09-07T11:26:15.000Z",
  "packages": {
    "loghq": {
      "root": "node_modules/loghq",
      "routes": ["routes/api.ts"],
      "views": ["resources/views"],
      "migrations": ["database/migrations"]
    }
  }
}

root is written by discovery, not by the package, and is relative to the project. Consumers resolve a package's files against it rather than assuming a location. It is relative so the file carries no machine-specific path.

The manifest is rewritten only when the discovered set actually changes. generated_at moves on every run, so comparing whole manifests would dirty the file on every boot. Two things depend on that: the file stays clean in version control, and its mtime means "when the package set last moved", which is what the auto-import staleness check reads to decide whether a newly installed package's models need to reach the barrel.

Shadowing

node_modules is scanned before the pantry tree, so a package the application actually depends on wins over a copy sitting in pantry/. When both exist, the manifest records which copy was ignored:

{
  "shadowed": [
    { "name": "loghq", "used": "node_modules/loghq", "ignored": "pantry/loghq" }
  ]
}

Opting a package out

An application can refuse a package it has installed:

{
  "stacks": {
    "dont-discover": ["loghq"]
  }
}

Migrations

A package's migrations are copied into the application's corpus before they run, not executed from node_modules. The runner treats its corpus as writable: SQLite preprocessing deletes duplicate CREATE TABLE statements and drops statements the dialect cannot execute. Running a package's directory in place would mean the framework deleting files inside an installed package, which the next install restores and the one after deletes again.

Staged files are named so they always run after the application's:

0000000133-add-orthomosaic.sql             the application, always first
9000000000-bughq__0000000001-issues.sql    then by package name
9000000000-loghq__0000000001-create.sql    then the package's own order
9000000000-loghq__0000000002-alter.sql

The ordinal 9000000000 is a reserved band. It matters because a package's tables carry foreign keys into the application's (user_id, team_id) and never the reverse: the application predates whatever it installed. A REFERENCES "users" on a table created before users fails on Postgres and MySQL while SQLite tolerates it, so getting this order wrong is green locally and red on deploy.

Every package migration shares that same ordinal, and the package name and the file's original name break the tie. That keeps a staged name stable no matter what else is installed. The migration ledger keys on the bare filename, so a name that shifted when another package was installed would read as new and run a second time against tables it had already created.

A package's own ordinal is kept rather than stripped, because create- before alter- is not something alphabetical order preserves.

Changing a shipped migration

A changed file is republished, so a fresh database builds what the installed version describes. It does not re-run on a database that already has it, because the ledger holds that filename. A package that needs to change a table it has already created ships another migration, exactly as an application does.