tomatophp/tomato-saas
Tomato SaaS is a Laravel/TomatoPHP package for building SaaS applications faster, with reusable modules for plans, subscriptions, tenants, and billing. It helps teams add monetization and multi-tenant features without starting from scratch.
tomato-saas is the subscription/plan layer of the TomatoPHP stack — it gives you plans, per-account subscriptions and feature gating on top of tomato-admin. It is billing + entitlement management, not tenant isolation.
composer require tomatophp/tomato-saas (pulls in the Tomato admin/framework stack — install that first if you don't have it).php artisan tomato-saas:install. It publishes config + migrations, migrates, and seeds the admin permissions/routes. Then build assets for the admin UI (npm install && npm run build, or publish the Tomato assets).config/tomato-saas.php — account model binding, feature definitions, route/prefix options.src/Services/SaaS.php — the helper facade (SaaS::getSubscription(), SaaS::getPlan(), SaaS::getFeatures(), SaaS::can('feature')).src/Traits/HasSaaS.php — the trait you put on your billable model.database/migrations/* — confirm the actual plans / subscriptions schema (columns and morph keys) rather than assuming.Account, Company, or User) and point the config at that class./admin → create two or three plans (e.g. free, pro) with feature keys, subscribe your own account, then gate one route with SaaS::can('reports') and confirm both the allowed and denied branches.First real use case: pick the single most valuable paid feature in your app, put it behind SaaS::can(), and leave everything else open. Ship that before building limit counters.
Feature keys are a public contract. Put every key in one PHP enum or constants class (Feature::REPORTS = 'reports'), seed plans from it, and reference only that class in policies, middleware, Blade, and tests. String keys scattered across the codebase are the #1 maintenance cost of this package.
Route-level gating for booleans, action-level checks for limits. Boolean features map cleanly to a route middleware group; numeric limits (seats, projects, API calls) belong at the write path — check used >= limit inside the create action, not in middleware, or you'll gate reads you didn't mean to gate.
Route::middleware(['auth', 'subscription:reports'])->group(function () {
Route::get('/reports', ReportController::class);
});
Register the alias in bootstrap/app.php/Kernel and apply it to groups, not individual routes — otherwise new routes silently ship ungated. Make the middleware content-negotiate: redirect to the plans page for HTML, 403 JSON for expectsJson().
Never trust the UI gate. @if(SaaS::can('reports')) in Blade is nav cosmetics; the controller/policy check is the actual enforcement. Write a test that hits the endpoint directly and asserts the denial.
Webhooks are the source of truth for billing. The package stores plan/subscription records; payment-provider wiring (Stripe/Paddle) is yours. Handle the webhook idempotently (event ID table), then update the subscription. Do not activate a plan from the checkout success redirect — users hit it twice, hit it after cancelling, and hit it with a stale session.
Testing. Build a small factory/helper that creates a plan + subscription for an account, and bind a fake into the container when you need deterministic entitlements. Assert both branches of every gate. Avoid $this->withoutMiddleware() on gated routes — you'll lose the only coverage that matters.
Admin screens as ops tooling. Plans/subscriptions CRUD ships via tomato-admin. When you need extra plan fields, publish and extend the migration + add the column to the form, rather than forking the package.
If you have real tenants, run a tenancy package alongside this and set the current account explicitly per request/job. tomato-saas will happily return the authenticated user's account — which is wrong inside a queued job.
SaaS:: resolves from the authenticated user. In jobs, commands, and scheduled tasks there is no auth: either resolve and pass the account explicitly, or you get null and your gate silently opens (or silently denies) everywhere. This is the single most common production bug with this package.null subscription means and test it. Free-tier users with no subscription row are a classic hole — either create a real free subscription on registration or handle the fallback explicitly in config. Don't let can()'s default decide for you.App\Models\Account leaves account_type pointing at a dead class and getSubscription() quietly returns null. When gating "stops working," check this first.SaaS::can() walks relations; memoize per request at minimum, and Cache::forget() the account's entitlement key when a plan is edited in admin or a webhook lands.composer update, diff vendor/tomatophp/tomato-saas/database/migrations against database/migrations and read the GitHub compare diff before deploying. Pin "tomatophp/tomato-saas": "1.1.*" and bump deliberately.tomato-saas:install is a first-run command; re-running it on an existing app can overwrite published config/routes. Use vendor:publish --tag=... --force consciously, and keep your customizations in the published copies under version control.403s that look like bugs in your own code.npm run build step before debugging PHP.resources/views/vendor/tomato-saas, middleware is swappable via config. That's your extension surface: a decorator bound over the SaaS service for custom resolution logic, a subclassed middleware for custom denial responses, and model events on subscriptions for side effects (provisioning, notifications, seat sync).How can I help you explore Laravel packages today?