Deploy
What a production deploy of a tbtop/admin app needs: matching package versions, the asset build, migrations, storage, and the page-discovery cache in the right order.
Deploying a Tabletop panel is a normal Laravel deploy with a few additions. The package runs no queued jobs and needs no scheduler, cache store or long-running process of its own. What it does need is a front-end build that includes the admin entry, its media tables and storage (plus Laravel's notifications table if the header shows the notification bell), and, if you use page discovery, a cached page index built before the route cache.
Before you ship: pin both packages together
tbtop/admin and @tbtop/inertia-admin are released together and share one wire format. A server running one version while the browser runs another produces pages that render wrong or not at all, often with no clear error. Lock both to the same release:
// package.json
"@tbtop/inertia-admin": "0.5.1"// composer.json
"tbtop/admin": "0.5.1"With ^0.5.1 instead, a later composer update can move PHP to a newer 0.5.x patch while npm stays on 0.5.1. Install covers both options. Commit composer.lock and package-lock.json, and install from them on the server (composer install, npm ci). When you upgrade, bump both in the same commit.
Build the assets
npm ci
npm run buildCheck three things about the build:
resources/js/admin.tsxis in the Viteinputlist. Otherwise@vite(['resources/js/admin.tsx'])inadmin.blade.phphas nothing in the manifest and the panel returns a server error.- Tailwind scans your PHP.
resources/css/admin.csscarries@source '../../app/**/*.php', because pages write Tailwind classes from PHP. If you build the assets somewhere without the PHP source (a separate CI job or a slim Docker stage), those classes are missing from the CSS and the affected blocks render unstyled. Build whereapp/is present. - No
public/hotfile on the server. If one is left over fromnpm run dev, Laravel serves assets from the dev server URL instead ofpublic/build.
After a deploy, browsers still on an old bundle reload themselves on their next navigation. Inertia's default asset version is a hash of public/build/manifest.json, so it changes with every build that changes the output. You need no extra step for this.
Migrate, and link storage
php artisan migrate --force
php artisan storage:linkThe package's media-library migrations load from the vendor directory and run with yours. The media library stores files on the disk named in config/tbtop-admin.php under media.disk, which is public by default. For those files to have working URLs, public/storage has to link to that disk, which is what storage:link does. On a symlinked-release deploy (Forge, Envoyer, Deployer), keep storage/ shared between releases so uploads survive a deploy.
To generate image variants (the thumb profile and any others under media.profiles), the server needs the PHP GD extension. Without GD, uploads still work, but no variants are generated.
Cache in the right order
Laravel's usual caches work with the package:
config:cacheis safe.config/tbtop-admin.phpholds only class names and scalars.route:cacheis safe. Every package route points at a controller class, and the panel-root redirect is built so it can be cached.
If a panel uses discoverPages(), build the discovery index before the route cache:
php artisan tbtop:cache-pages
php artisan config:cache
php artisan route:cache
php artisan view:cacheWhy the index matters even with cached routes: the panel reads its page list on every request, to build the menu and the breadcrumbs. Without the index, each request walks the discovery directories on disk. tbtop:cache-pages writes that list to bootstrap/cache/tbtop-pages.php once.
Facts about the index worth knowing:
php artisan optimizedoes not build it, andoptimize:cleardoes not remove it. Runtbtop:cache-pagesexplicitly, and beforeoptimizeif you use that.- Rebuild it on every deploy that adds, moves, renames or removes a page, or that changes a page's
isDiscovered(). The simplest rule is to run it on every deploy. A new page missing from a stale index is not routed. - It survives symlinked releases. Paths inside the app are stored relative to the project root, so an index built in one release directory still applies after the symlink moves.
- A stale index degrades instead of failing. If the index names a class that no longer exists, it is ignored and the directories are scanned again. The rebuild is also atomic: a failed
tbtop:cache-pagesleaves the previous index in place. - It holds class names only. Gates, middleware and page output are evaluated live on every request.
Panels that list pages only through pages() have no index to build, and tbtop:cache-pages has nothing to do for them.
The route cache freezes each panel's prefix, middleware and page paths. Any change to a panel class, or to the panels list in the config, needs a fresh route:cache to take effect.
To go back to uncached behaviour, for example while debugging on a server:
php artisan tbtop:clear-cached-pages
php artisan route:clearRestart workers
If you run Octane or another long-lived PHP process, restart it after each deploy. A worker keeps the page list it loaded at boot. Queue workers need the usual queue:restart if your own handlers dispatch jobs, but the package does not queue anything itself.
A deploy script
Put together, for a panel with page discovery:
composer install --no-dev --optimize-autoloader
npm ci
npm run build
php artisan migrate --force
php artisan storage:link # once per server; harmless to repeat
php artisan tbtop:cache-pages # before route:cache
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan octane:reload # only if you run OctaneProduction checklist
- The Inertia middleware is in the
webgroup and sharesauth.user(see Install and Authentication). - Guests are redirected to a login page inside the panel, and
POST {prefix}/logoutexists. - The panel is limited to staff, not merely to signed-in users. See access control.
- Sign-in is rate-limited. The package does not do this for you.
media.url_import.allowed_hostsinconfig/tbtop-admin.phpis set if editors should import media only from known hosts. Empty means any host that passes the built-in SSRF guard.media.acceptandmedia.max_sizematch what editors should be able to upload.max_sizeis in kilobytes. Uploaded SVGs are sanitized on the server, and HTML files are always refused.
Your first page
Build a list, a create form and an edit form for a Post model, from the make:tbtop-page scaffold to a working save.
Pages
How a Page class maps to a route, how view() builds its tree with the S builder, how one page composes any number of forms, tables and display blocks, and how a CRUD resource becomes a family of pages.