Tenancy Kit documentation
Multi-tenant Laravel 12 SaaS starter · version 1.0.0
- Requirements
- Installation
- Configuration
- Feature usage
- FAQ
- Deploy with Laravel Forge or Ploi
- Test suite and code quality
- Working on the kit with AI coding agents
- Upgrading
- Changelog
Requirements
| Component | Requirement |
|---|---|
| PHP | 8.2+ |
| PHP extensions | bcmath, ctype, curl, fileinfo, intl, mbstring, openssl, pdo (+ pdo_mysql / pdo_pgsql / pdo_sqlite), tokenizer, xml, zip, sodium |
| Database | MySQL 8.0+, MariaDB 10.6+ or PostgreSQL 15+ in production; SQLite for local use and tests (see Compatibility) |
| Cache / queue | File cache + database queue drained by cron (works on cPanel shared hosting); Redis + Horizon optional on a VPS |
| Cron | * * * * * php artisan schedule:run |
| Composer & Node | Composer 2; Node 20+ only if you rebuild front-end assets (prebuilt assets ship in public/build) |
| Web server | Any PHP host with the web root at public/; wildcard subdomain DNS for {tenant}.yourapp.com; Caddy (on-demand TLS) or Cloudflare for SaaS for tenant custom domains |
| Payments (optional) | Stripe account and/or Paddle Billing account |
| DB-per-tenant mode | Database user with CREATE DATABASE rights |
Stack: Laravel 12 · Filament 5 / Livewire 4 · Laravel Cashier (Stripe) 15 · Cashier Paddle 2 · spatie/laravel-permission 6 (teams) · Sanctum · Socialite · Pest · Larastan · Pint.
Compatibility (1.0.0)
| Platform | Version | Status |
|---|---|---|
| PHP | 8.2 | Tested in CI (Pest, both tenancy modes; also Larastan and the browser job) |
| PHP | 8.3 | Tested in CI (Pest, both tenancy modes) |
| PHP | 8.4 | Tested in CI (Pest, both tenancy modes) |
| SQLite | 3 (bundled with PHP, pdo_sqlite) |
Tested in CI (all tests run on in-memory SQLite); for local use and tests, not production |
| MySQL | 8.0+ | Supported per docs (not in CI) |
| MariaDB | 10.6+ | Supported per docs (not in CI) |
| PostgreSQL | 15+ | Supported per docs (not in CI) |
CI: .github/workflows/ci.yml (PHP from composer.json: ^8.2). Database versions are from docs/install.md. CI runs no MySQL, MariaDB or PostgreSQL job.
Also: Laravel 12.x · Filament 5.x / Livewire 4.x · single-DB and DB-per-tenant tenancy (CI runs both) · cPanel shared hosting, VPS, Laravel Forge, Ploi.
Installation
Option A: Laravel Sail (Docker, optional local dev box)
Docker is not needed to run the kit. Sail is included (compose.yaml: PHP 8.4, MySQL 8, Mailpit) for buyers who prefer a container.
unzip tenancy-kit-1.0.0.zip -d my-saas && cd my-saas
composer install # the zip ships production vendor/; this adds Sail, Pest, Larastan, Pint
cp .env.example .env
# .env: DB_HOST=mysql DB_DATABASE=tenancy DB_USERNAME=sail DB_PASSWORD=password MAIL_HOST=mailpit MAIL_PORT=1025 APP_URL=http://localhost
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate
./vendor/bin/sail artisan kit:install --demo
Open http://localhost/admin and http://localhost/app. Mail goes to Mailpit at http://localhost:8025. For tenant subdomains locally use northwind.localhost, or path lookup /t/northwind.
Option B: Plain PHP / CLI
cp .env.example .env # set DB_*, APP_URL, TENANCY_* values
composer install
php artisan key:generate
php artisan kit:install # interactive
Non-interactive:
php artisan kit:install --no-interaction --name="My SaaS" --url=https://saas.example.com \
--tenancy=single --admin-email=you@example.com --admin-password='at-least-10-chars' --license=AUCIP-XXXXX-XXXXX-XXXXX-XXXXX [--demo]
The installer checks requirements, writes .env (app name, URL, tenancy mode, central domain), migrates, seeds the Free/Starter/Pro plans, creates the super-admin, optionally seeds demo data, and writes storage/installed.lock. Re-run with --force.
Option C: cPanel / shared hosting (no SSH, Docker, Redis or root)
Tested profile: single-database tenancy, MySQL, file cache/session, database queue drained by cron, path and wildcard-subdomain tenant lookup.
-
Upload: cPanel → File Manager → upload
tenancy-kit-1.0.0.zipand Extract. Recommended: extract to e.g./home/USER/tenancy-kitand set the domain's Document Root to/home/USER/tenancy-kit/public(cPanel → Domains → Manage). If you can't change the document root, extract intopublic_html; the bundled root.htaccess+index.phpforward topublic/and deny access to code,.env,vendor/andstorage/. Make surestorage/andbootstrap/cache/are writable. -
PHP: cPanel → Select PHP Version / MultiPHP Manager: PHP 8.2+ with
intl,sodium,zip,bcmath,pdo_mysql,fileinfo. -
Database: cPanel → MySQL Databases: create a database and user, and grant the user All privileges.
-
Web installer: open
https://your-domain/install(a fresh upload redirects there and creates.env+APP_KEY). Enter your licence key, app name/URL, the database and the super-admin. If the licence server is unreachable the install still finishes and the key is retried daily (14-day grace); a rejected key stops the install. The installer locks itself afterwards. -
Cron (one line, every minute):
cd /home/USER/tenancy-kit && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1
Use your host's PHP path (e.g. /opt/cpanel/ea-php82/root/usr/bin/php). This drains the database queue, re-verifies the licence daily and re-checks custom domains hourly.
6. Tenant URLs:
* Path: https://your-domain/t/{slug} and the tenant panel at /app/{slug}. These work immediately.
* Subdomains: create the domain *.your-domain in cPanel on the same document root, plus a wildcard DNS record *. For HTTPS use AutoSSL if your host issues wildcard certificates, otherwise Cloudflare proxy.
* Custom domains: after the tenant's TXT record verifies, add the domain as an Alias/Parked domain in cPanel (AutoSSL covers it), or use Cloudflare for SaaS.
7. Storage link: if public/storage is missing, run php artisan storage:link from cPanel Terminal, or use S3-compatible storage.
Public demo build
tenancy-kit-1.0.0-demo.zip contains a DEMO_PACKAGE marker: the installer skips the licence step, writes DEMO=true and always seeds demo data. Demo mode hides the licence banner and makes Plans, Tenants and Users read-only. The cron resets the demo data hourly.
Disable the web installer later with AUCIP_WEB_INSTALLER=false.
Demo logins (--demo)
| Role | Password | |
|---|---|---|
| Tenant owner (Northwind Studio, Harborlane Logistics) | owner@demo.test | password |
| Member (Northwind Studio) | member@demo.test | password |
| Super-admin | the email/password you entered in the installer |
After install
- Run a queue worker (
php artisan horizonorqueue:work), or keepQUEUE_VIA_CRON=trueon shared hosting, and add the scheduler cron. - Configure billing (see Configuration) and optional Google/GitHub login.
- Activate your licence under Admin → Licence.
Upgrading
See Upgrading.
Configuration
Tenancy (.env / config/tenancy.php)
| Key | Default | Meaning |
|---|---|---|
TENANCY_MODE |
single |
single: one database, tenant_id scoping. database: one database per tenant (tenant_{id}). Choose before you have production data. |
TENANCY_CENTRAL_DOMAINS |
APP_URL host | Hosts that serve the marketing site; tenants get {slug}.{first host}. |
TENANCY_FALLBACK |
central |
When no tenant matches: central (marketing site), session (user's current org) or abort. |
INVITATION_EXPIRE_HOURS |
72 | Hours before a team invitation expires. |
TENANCY_DB_PREFIX |
tenant_ |
DB-per-tenant database name prefix. |
Tenant identification order: verified custom domain → {slug}.{central domain} → /t/{slug}. The Filament tenant panel lives at /app/{slug}.
Tenant isolation bootstrappers: database connection (DB-per-tenant mode), cache prefix tenant_{id}_, filesystem roots tenants/{id} for the local/public disks (S3 key prefix), and spatie permission team id.
Billing
The tenant (organization) is the billable entity.
Stripe (primary): STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_STARTER, STRIPE_PRICE_PRO, optional metered STRIPE_PRICE_API_CALLS (Billing Meter event STRIPE_METER_EVENT=api_calls), CASHIER_CURRENCY. Webhook URL: https://your-app/webhooks/stripe. Enable the customer portal and Smart Retries in Stripe.
Paddle Billing: PADDLE_SANDBOX, PADDLE_SELLER_ID, PADDLE_CLIENT_SIDE_TOKEN, PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, PADDLE_PRICE_STARTER, PADDLE_PRICE_PRO. Webhook URL: https://your-app/webhooks/paddle. Gateway per tenant via tenants.billing_gateway; default BILLING_GATEWAY=stripe.
Plans live in the plans table, editable in Admin → Plans (seeded: Free $0, Starter $9/seat, Pro $29/seat, 14-day trials on paid plans). Each plan has features (e.g. api, custom_domains) and limits (projects, seats; empty = unlimited). Gate routes with feature: / limit: middleware or the Features helper.
Webhook secrets are required
/webhooks/stripe and /webhooks/paddle answer 403 and log a warning until STRIPE_WEBHOOK_SECRET / PADDLE_WEBHOOK_SECRET is set, in every environment. Requests with a missing or wrong signature are also rejected. For local testing, stripe listen --forward-to your-app.test/webhooks/stripe prints a whsec_… secret for .env.
Adding a plan, feature or limit
- Plan: Admin → Plans → New plan: name, slug, monthly price, trial days, per-seat, and the Stripe/Paddle price IDs. The marketing pricing page and the tenant Billing page read the plans table, so the new plan appears immediately.
- Feature gate: add a key (e.g.
exports) to the plan'sfeatures, then protect routes with->middleware('feature:exports')or checkFeatures::allows($tenant, 'exports')in code and Blade. Tenants without it get a 402 "Your plan does not include this feature." - Limit: add a key to the plan's
limits(e.g."reports": 10; empty = unlimited), register a counter inconfig/billing.php→counters('reports' => [Counters::class, 'reports']), and add->middleware('limit:reports')to the create route, or checkFeatures::withinLimit().
Proxies and allowed hosts
TRUSTED_PROXIES(default empty): no proxy is trusted, so visitors can't spoofX-Forwarded-Host/-Proto/-Forto redirect password-reset links or switch tenants. That's right for cPanel/Apache. Behind Caddy/Nginx on the same server use127.0.0.1; behind Cloudflare list its published IP ranges; behind a private load balancer use its CIDR. Use*only if the app can't be reached except through the proxy.TRUSTED_HOSTS: requests are accepted only for theAPP_URLhost, central domains and their tenant subdomains, and verified custom domains; anything else gets 400. Add extra hostnames (staging, a health-check IP) comma separated.APP_ENV=localskips the check.
Social login
GOOGLE_CLIENT_ID/SECRET, GITHUB_CLIENT_ID/SECRET. Buttons show on the tenant login form.
Standard Laravel MAIL_* settings. Invitations, dunning, receipts and trial-ending emails use them.
Custom domains & SSL
- Plans with the
custom_domainsfeature get the Custom domains page. - Tenants add a TXT record
_aucip-verify.<domain>=aucip-verify=<token>and a CNAME to your app host. - Caddy: use
deploy/Caddyfile. On-demand TLS asksGET /caddy/ask?domain=…, which only approves verified custom domains and existing tenant subdomains. - Cloudflare for SaaS: add Custom Hostnames per tenant domain; the TXT check stays the ownership proof.
Licence & updates
AUCIP_PRODUCT, AUCIP_PUBLIC_KEY, AUCIP_WEB_INSTALLER, AUCIP_DEMO. Activate the key in the installer or Admin → Licence. The licence is re-verified once a day by the scheduler (cron); web requests never call home. If verification fails, a 14-day grace period starts (counted from the last successful check), with a banner in the admin panel; network errors are retried automatically. After 14 days, admin settings (Plans) and Updates lock until the licence verifies again. Tenants, users, billing, webhooks, the audit log and the tenant-facing app never lock, and no customer data is touched. To move domains: Admin → Licence → Deactivate, then activate on the new domain. Updates are free while licensed; support is 6 months, renewable.
Feature usage
Marketing site
/ (landing page) and /pricing render plans straight from the plans table. Tenant homes are served on custom domains, subdomains or /t/{slug}.
Tenant panel (/app)
- Register / log in: email + password, email verification, password reset, optional Google/GitHub login, and TOTP two-factor authentication with recovery codes (Profile).
- Create organization:
/app/new; switch between organizations from the sidebar menu. - Members: invite by email (expiring links), change roles (Owner / Admin / Member / custom roles), remove members, transfer ownership, see pending invitations.
- Billing: current plan, seats and metered API usage; choose / swap plans, cancel and resume; open the billing portal.
- Custom domains: add a domain, see the TXT record, "Verify now". Verification is re-checked hourly; after 3 failures a domain is un-verified.
- API tokens: create organization-scoped Sanctum tokens.
- Projects: an example tenant-owned resource (with plan limits) to copy for your own models.
- Organization settings: name and billing email.
Super-admin panel (/admin)
- Dashboard: MRR, active subscriptions, churn (30 days), organization count.
- Tenants, Users, Plans: manage records; edit plan prices, trials, features and limits.
- Impersonation: log in as a user from Users ; a banner shows during impersonation and every start/stop is written to the Audit log.
- Audit log: events with actor, impersonator, organization and properties.
- Licence and Updates pages.
- Two-factor authentication available for admins.
REST API
/api/v1 with a Sanctum token bound to an organization:
GET /api/v1/me: current user and tenantGET|POST|PUT|DELETE /api/v1/projects: example resource (enforces theprojectslimit)
Billing lifecycle
Checkout (trial if the plan has trial_days) → webhook creates the subscription → plan sync → seat quantity follows member join/remove on per-seat plans → swap → cancel (grace period) → resume → invoice.payment_failed sends a dunning email per attempt → customer.subscription.deleted drops the tenant to the Free plan. Webhooks are processed once (webhook_events table).
Tenant-aware code
use App\Tenancy\Tenancy;
app(Tenancy::class)->run($tenant, function () {
SendReport::dispatch(); // queued job runs in $tenant's context
});
$schedule->command('tenants:run', ['cmd' => 'reports:weekly'])->weekly(); // once per tenant
Add BelongsToTenant to a model to get a global tenant_id scope, auto-fill on create and blocked cross-tenant writes.
Pitfalls
- Return nothing from the
run()closure when dispatching: writeSendReport::dispatch();as a statement.fn () => SendReport::dispatch()returns a PendingDispatch that is sent after the tenant context ends, so the job runs without a tenant. - Removing a member revokes access immediately through the per-request membership check and deletes their API tokens. With the default
SESSION_DRIVER=file, their existing session files aren't deleted, but they can't open the organization any more. Use thedatabaseorredissession driver if you need sessions removed too.
Artisan commands
| Command | Purpose |
|---|---|
kit:install |
Install wizard (CLI twin of /install) |
tenants:migrate |
Run database/migrations/tenant for each tenant DB |
tenants:run {cmd} |
Run an artisan command for every tenant |
domains:verify |
Re-check custom domains (also scheduled hourly) |
demo:reset |
Wipe and re-seed the public demo |
Testing and AI agents
See Test suite and code quality and Working on the kit with AI coding agents.
FAQ
Single database or database per tenant? Both. single (default) scopes rows by tenant_id; database gives each tenant its own database. Pick before production data exists; switching later needs a manual export and import.
Which payment providers? Stripe (Cashier 15) and Paddle Billing (Cashier Paddle 2), chosen per tenant.
Can I run it on shared hosting? Yes, on plain cPanel (see Installation → cPanel). Use the /install web installer and one cron line; with QUEUE_VIA_CRON=true the scheduler drains the database queue every minute.
How do tenant custom domains get SSL? Caddy on-demand TLS (with the built-in /caddy/ask guard) or Cloudflare for SaaS.
Is Filament 's tenancy enough? The tenant panel uses Filament's native tenancy, and the kit adds tenant-aware jobs, cache and storage, DB-per-tenant mode, billing and custom domains.
Does it contact Aucip? Only licence and update checks are sent to aucip.com: the key is activated in the installer, then re-verified once a day by the scheduler (php artisan schedule:run cron). One-click updates also use it. Web requests never call home.
What if verification fails? You get a 14-day grace period (counted from the last successful check), with a banner in the admin panel. Network problems are retried automatically. After 14 days, admin settings (Plans) and Updates lock until the licence verifies again. Tenants, users, billing, webhooks, the audit log and the tenant-facing app never lock, and no customer data is touched.
What support and updates are included? Free updates and 6 months support, renewable.
Which licence do I need? Regular licence: use in one end product that end users are not charged for. Free updates while licensed and 6 months of support (renewable). Extended licence: use in one end product that end users are charged for (for example a paid SaaS or paid membership). Free updates while licensed and 6 months of support (renewable). One licence = one end product. Free local installs (localhost/.test) plus up to 3 staging installs on staging subdomains per licence; staging doesn't count against your production domain. Each additional end product needs another licence. One licence per client end product: Regular, or Extended when that product's end users are charged. Cannot be resold as a starter kit. Installation service: paid add-on, sold separately.
Is there a React/Vue front end? Not in 1.0. The UI is Blade + Filament/Livewire.
Where do I add my own features? Copy the Projects example: a tenant-owned model using BelongsToTenant, a Filament resource and an API controller.
How it compares
Why not build multi-tenancy myself? You can, but the work isn't the tenant_id column. It's making queued jobs, cache keys, file storage, scheduled tasks, roles and API tokens tenant-aware, failing closed when no tenant is set, and proving it with tests. The kit ships all of that plus an isolation test suite that CI runs in both tenancy modes.
Single database or database per tenant?
| Single DB (default) | DB per tenant
---|---|---
Isolation | tenant_id global scope; writes to another tenant refused | separate database per tenant
Hosting | any host, including cPanel | DB user needs CREATE DATABASE
Migrations | one php artisan migrate | php artisan tenants:migrate across all tenant DBs
Backups / per-customer export | filter by tenant_id | dump one database
Best for | most B2B SaaS, many small tenants | fewer, larger customers with data-residency or export needs
Same application code works in both; pick before you have production data. | |
How is this different from a hosted SaaS boilerplate or platform? You buy the full source once, host it wherever you like (shared hosting included) and own every line. There is no per-seat fee and no vendor platform between you and your customers. Only licence and update checks go to aucip.com.
How is it different from single-tenant Laravel starters? Those give you auth and a subscription per user. Here the organization is the billable entity, with teams, roles, per-tenant domains, impersonation and an audit log built in.
What about Filament 's own tenancy? The tenant panel uses it, and the kit adds what it leaves to you: tenant-aware jobs, cache and storage, DB-per-tenant mode, billing and custom domains.
Deploy with Laravel Forge or Ploi
Both panels provision a standard PHP server, so the steps are the same. Menu names differ slightly between the two; the setting each step needs is given in brackets.
1. Server
- Create a server with PHP 8.2 or newer , MySQL 8 (or PostgreSQL 15+) and, optionally, Redis.
- Extensions the kit checks for: bcmath, ctype, curl, fileinfo, intl, mbstring, openssl, pdo, tokenizer, xml, zip, sodium. These are standard on both panels' PHP builds.
2. Site
- Create a site for your central domain, e.g.
saas.example.com, with web directory/public. - Add the alias
*.saas.example.com(Forge: Aliases ; Ploi: Site aliases / wildcard) so tenant subdomains reach the app. - DNS: an
Arecord forsaas.example.comand a wildcard*record, both pointing at the server.
3. Code
- Connect your Git repository (push the kit to your own private repo first), or upload the zip contents over SFTP.
- Create the database and user in the panel.
4. Environment
Edit .env in the panel's environment editor. At minimum:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://saas.example.com
TENANCY_CENTRAL_DOMAINS=saas.example.com
DB_CONNECTION=mysql
DB_DATABASE=… DB_USERNAME=… DB_PASSWORD=…
QUEUE_CONNECTION=database # or redis
QUEUE_VIA_CRON=false # a daemon runs the queue (step 6)
TRUSTED_PROXIES= # empty unless something proxies in front (see Configuration)
STRIPE_WEBHOOK_SECRET=… # webhooks answer 403 until set
5. First install (SSH, once)
php artisan key:generate
php artisan kit:install # app name, URL, tenancy mode, super-admin, licence key
6. Scheduler and queue
- Scheduler: enable the panel's scheduler (Forge: Scheduler →
php artisan schedule:runevery minute; Ploi: Cron jobs). It runs the daily licence check, the hourly custom-domain re-check and anytenants:runtasks. - Queue worker daemon:
php artisan queue:work --tries=3(Forge: Daemons / Queue ; Ploi: Queue workers / Daemons). If you install Horizon (composer require laravel/horizon), runphp artisan horizoninstead and setQUEUE_CONNECTION=redis.
7. Deploy script
cd $SITE_PATH # the panel sets the site directory
git pull origin $BRANCH
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan tenants:migrate # DB-per-tenant mode; harmless in single-DB mode
php artisan optimize
php artisan queue:restart # or: php artisan horizon:terminate
Assets ship prebuilt in public/build; add npm ci && npm run build only if you change front-end code.
8. TLS
- Central domain and subdomains: request a Let's Encrypt certificate for
saas.example.comand*.saas.example.com. Wildcard certificates need DNS validation, so connect your DNS provider in the panel, or put Cloudflare in front. - Tenant custom domains: panels issue certificates per domain you add manually. To make it automatic, either run Caddy with on-demand TLS in front (
deploy/Caddyfile; the kit's/caddy/askendpoint approves only verified domains and setTRUSTED_PROXIES=127.0.0.1), or use Cloudflare for SaaS custom hostnames.
9. After deploy
- Log in at
/admin, check Admin → Licence. - Set billing keys and webhook endpoints (
/webhooks/stripe,/webhooks/paddle). - Visit a tenant subdomain to confirm wildcard DNS and TLS.
Test suite and code quality
The buyer zip includes the Pest test suite, the tenant-isolation suite, Larastan and Pint configuration, and a GitHub Actions workflow. The zip ships production vendor/, so install the dev tools first:
composer install # adds Pest, Larastan, Pint, Sail
cp .env.example .env && php artisan key:generate # if you haven't installed yet
| Command | What it runs |
|---|---|
composer test |
Pest suite (single-DB mode) |
TENANCY_MODE=database composer test |
same suite in DB-per-tenant mode |
vendor/bin/pest --group=isolation |
tenant-isolation suite: queries, cache keys, jobs, files, API tokens and roles cannot cross tenants |
composer analyse |
Larastan (phpstan.neon, level 6) |
composer lint |
Pint in check mode (pint.json); vendor/bin/pint fixes |
Tests run on SQLite and need no Stripe/Paddle accounts (gateways are mocked; webhooks use fixtures).
CI: .github/workflows/ci.yml runs Pint and Larastan, the Pest suite with a 70% coverage floor on PHP 8.2, 8.3 and 8.4 in both tenancy modes, and a browser job (Playwright + axe accessibility scan and a 375px overflow check, tests/Browser/a11y.mjs). Push the kit to your own GitHub repo and it runs as-is.
Keep all of these green in both tenancy modes when you change the kit.
Working on the kit with AI coding agents
These rules keep Claude Code, Cursor, Copilot, Codex and similar agents from breaking tenant isolation. Copy this page into an AGENTS.md (or CLAUDE.md, .cursor/rules) at the root of your project so your agent reads it on every task.
Rules for the agent
- Tenant-owned models use
App\Tenancy\Concerns\BelongsToTenant, and their migrations live indatabase/migrations/tenant/with$table->unsignedBigInteger('tenant_id')->index(). Never put tenant-owned tables indatabase/migrations/. - Never bypass the tenant scope in tenant code (
withoutGlobalScopes(), rawDB::table('projects')). Cross-tenant reads belong only in the super-admin panel, on purpose. - Central tables are not tenant-scoped: users, tenants, tenant_user, domains, plans, invitations, subscriptions, audit_logs, personal_access_tokens, roles. Filter them by
tenant_idyourself. - Run work for a tenant with
app(Tenancy::class)->run($tenant, fn () => …); read the current one withtenant(). Outside a tenant context, tenant models return no rows and refuse writes. - Jobs: dispatch inside a tenant context and the job runs in that tenant (
tenant_idtravels in the payload). Use statement form,MyJob::dispatch();. Do not return the dispatch from the closure (fn () => MyJob::dispatch()): the PendingDispatch is sent after the context ends and the job loses its tenant. - Cache and storage are namespaced per tenant automatically. Don't build
"tenant:{$id}:…"keys by hand. - Billing goes through
App\Billing\BillingManager. The tenant is the billable entity, never the user. UseFeatures::plan($tenant),Features::allows(),Features::withinLimit()and thefeature:<name>/limit:<key>middleware. - Security-relevant actions call
App\Support\Audit::log(). - User-facing strings go through
__(). - No outbound HTTP from web requests except Stripe/Paddle (a test enforces this). The licence API is called only by the installer, the admin Licence/Updates screens and the daily
aucip:licensecron.
Where things live
| Concern | Path |
|---|---|
| Tenant context and bootstrappers | app/Tenancy/ |
| Tenant identification (domain → subdomain → path) | app/Tenancy/TenantResolver.php, middleware tenant |
| Teams, invitations, roles, ownership | app/Services/TeamService.php, app/Support/TenantRoles.php |
| Billing and webhooks | app/Billing/, app/Http/Controllers/Webhooks/ |
| Custom domains, Caddy ask | app/Domains/, deploy/Caddyfile |
| Impersonation | app/Services/ImpersonationService.php |
Super-admin panel (/admin) |
app/Filament/Admin/ |
Tenant panel (/app/{tenant}) |
app/Filament/App/ |
| Installer, updater, demo mode | packages/aucip-starter/ |
| Brand tokens and theme | app/Support/Brand.php, resources/css/brand/tokens.css, resources/css/filament/theme.css |
Recipes to give the agent
- New tenant-owned resource: copy
app/Models/Project.php, thedatabase/migrations/tenant/*projects*migration andapp/Filament/App/Resources/Projects/. Keep$isScopedToTenant = falseon the Filament resource (scoping comes fromBelongsToTenant, which also works in DB-per-tenant mode). - New plan feature: add the key to a plan's
featuresin Admin → Plans, then protect routes with->middleware('feature:my_feature')or checkFeatures::allows($tenant, 'my_feature'). - New plan limit: add it to the plan's
limits, register a counter inconfig/billing.php→counters, and add->middleware('limit:my_key'). - Per-tenant scheduled task:
$schedule->command('tenants:run', ['cmd' => 'my:command'])->daily();
Definition of done for every agent change
composer test
TENANCY_MODE=database composer test
vendor/bin/pest --group=isolation
composer analyse
composer lint
Ask the agent to add or extend a test in tests/Feature/Isolation/ whenever it touches tenant data access.
Upgrading
One-click (recommended)
Admin → System → Updates → "Check for updates" → Install. Updates are free while your licence is valid. The updater takes a code backup first, puts the site in maintenance mode, copies the new files (keeping .env and storage/), runs central and tenant migrations, and restores the code backup if a step fails (database changes are not rolled back, so back up your database before updating). You can also upload a release zip on the same screen.
Manual (SSH)
Extract the new zip over the app, keeping .env and storage/, then:
php artisan migrate --force
php artisan tenants:migrate
php artisan optimize:clear
Using Git (Forge/Ploi): merge the new release into your repo and run the deploy script.
Per-release notes
1.0.0
First public release. No upgrade steps.
Future releases list any breaking changes, new .env keys and manual steps here.
Changelog
1.0.0
- Initial release.
- In-house tenancy layer: single-DB and DB-per-tenant modes; tenant-aware queue, cache, storage, scheduler (
tenants:run) and migrations (tenants:migrate). - Tenant identification by custom domain, subdomain and
/t/{slug}path with configurable fallback. - Teams: organizations, org switcher, expiring email invitations, Owner/Admin/Member + custom roles, ownership transfer.
- Per-tenant billing with Cashier Stripe and Cashier Paddle: plans, trials, per-seat quantity, metered API-calls example, swap/cancel/resume, billing portal, idempotent webhooks, dunning emails.
- Custom domains with DNS TXT verification, hourly re-check, plan gating, Caddy on-demand TLS ask endpoint.
- Filament 5 super-admin panel (tenants, users, plans, MRR/churn, audit log, impersonation with banner) and tenant panel.
- Auth: email/password, verification, TOTP 2FA with recovery codes, Google/GitHub login, tenant-scoped Sanctum tokens.
kit:installCLI + web installer, demo seeder, Sail, Pest, Larastan, Pint, CI, AGENTS.md.
Aucip Tenancy Kit · aucip.com