Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Tomato Saas Module Laravel Package

tomatophp/tomato-saas-module

A SaaS subscription module for TomatoPHP that adds plan management, tenant subscriptions, and billing scaffolding to Laravel apps, letting you define plans, attach them to accounts, and manage recurring access from a ready-made admin panel.

View on GitHub
Deep Wiki
Context7

Getting Started

The first decision is whether you should install this at all. tomatophp/tomato-saas-module supplies the plumbing of a subscription SaaS — tenants, plans, subscriptions — and a Filament admin surface on top. It does not supply billing, invoicing, dunning, or usage metering. If your app is not on the TomatoPHP/Filament admin stack, installing it means importing migrations and models while discarding most of the shipped value. That is a bad trade. Assume Filament is a prerequisite, not an optional extra.

Assuming it is:

composer require tomatophp/tomato-saas-module:1.0.0

Pin the exact version from day one. The only release note for v1.0.0 is a link to a commit list — no migration notes, no back-compat statement. You are not entitled to assume ^1.0 is safe.

Then publish and migrate. The provider namespace for TomatoPHP modules typically follows TomatoPHP\SaaS\SaaSServiceProvider style naming, so verify with:

php artisan vendor:publish --provider="TomatoPHP\SaaS\SaaSServiceProvider"
php artisan migrate

Run that once before migrate to see what tags exist, rather than guessing --tag=saas-config. A wrong tag is a silent no-op: no error, no file, and a config that looks configured but isn't.

Read these four things, in this order, before writing application code:

  1. Migrations. This is the schema contract — tenant table shape, plan/price columns, subscription state columns, and critically the tenant discriminator column and where it lives. Almost every architectural question about this package ("is this shared-DB or DB-per-tenant?") is answered here, not in a README.
  2. The service provider's boot()/register(). What it binds into the container, what middleware aliases it registers, what global scopes it attaches. This is where hidden behaviour lives.
  3. The config file. Model bindings, feature/limit definitions, middleware, and any billing hooks. If the answer to "where do I configure X?" is not migrations, it is this file.
  4. The Filament resources. These document the relations and workflow the package author expected you to have — and they double as a template for your own resources.

First real use case: prove tenancy before touching billing. Seed two tenants, register the tenant-resolution middleware on a route group, hit a route under each tenant, and assert that TenantScopedModel::find($otherTenantsId) returns null rather than the record. Everything downstream (subscriptions, entitlements, feature gates) is meaningless if that assertion fails. Make it a failing test first, then make it pass.

Implementation Patterns

Draw the ownership line explicitly, in writing. The package owns: tenant resolution, the plan catalog, subscription state records. Your app owns: domain models, business logic, the entitlement enforcement layer, and the billing provider integration. Anything you can't cleanly assign means you haven't read enough of the source yet.

Plans are seed data, referenced by slug — never by ID. A PlanSeeder is the mechanism that keeps dev, staging, and prod converged, and lets a pricing experiment be a seeder change instead of a migration. In application code reference 'pro-monthly', not 1. IDs drift between environments and between test runs, and a hardcoded ID is a production incident waiting for a re-seed.

One entitlement gate, and only one. Pick a single enforcement point — a Gate::define, a FeatureGate service, or a policy base class — and route every check through it: middleware for page access, policies for model actions, guard clauses for API endpoints and jobs. When a feature moves tier, you edit config and a seeder. When you have three enforcement points, you have three subtly different answers to "can this tenant do X?" and you will eventually ship a gated feature ungated.

Resolve tenant through the container, not through request(). Inject a TenantContext (or whatever the module's equivalent is, wrapped in your own thin service if needed) rather than calling request()->getHost() or subdomain() inside domain code. The moment a job, an artisan command, or a Livewire action needs the tenant and there is no HTTP request, the difference between "works" and "silently writes to the wrong tenant" is exactly this decision.

Rebind tenant context explicitly in every job and command. There is no ambient request, so there is no fallback:

class SendInvoiceEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $tenantId,
        public int $invoiceId,
    ) {}

    public function handle(TenantContext $context): void
    {
        $context->set(Tenant::findOrFail($this->tenantId));
        // tenant-scoped work happens here
    }
}

Do this at the top of the handle() method, before anything touches a scoped model. Serialize the tenant ID, not the tenant object — SerializesModels on a tenant model is fine, but an int is unambiguous across refactors.

Billing lives behind your interface, not the package's. Define a narrow contract:

interface BillingProvider
{
    public function subscribe(Tenant $tenant, Plan $plan, array $payload): Subscription;
    public function cancel(Subscription $subscription): void;
    public function findSubscription(Tenant $tenant): ?Subscription;
    public function handleWebhook(Request $request): void;
}

Map provider webhook events onto the package's subscription records inside that service. The payoff is that swapping Stripe for Paddle, adding idempotency keys, or writing a fake provider for tests never forces you to fork the module. Because the package's billing surface is thin, you'd be writing most of this anyway — the interface just stops it from leaking into controllers.

Extend the Filament resources, don't edit them. The supported pattern is to register your own resource alongside the package's for anything you need to change materially, or use Filament's resource extension points for small additions. Published migrations are yours to own and edit; vendor files are not.

Two tenancy migration strategies, both explicit. Shared-database: your models carry the package's global scope, and withoutGlobalScope appears only in genuinely cross-tenant code — admin reports, exports, backfills — ideally behind a helper so grep withoutGlobalScope returns a short, reviewable list. Database-per-tenant: php artisan migrate becomes a loop over tenants in the deploy script; a single migration run will silently leave every tenant but one stale, and you'll find out on the first request after deploy.

Write the adversarial tests as a reusable base case. A TenantTestCase that boots a tenant, then at minimum:

  • Tenant A's find() on Tenant B's record returns null.
  • A job dispatched under Tenant A runs under Tenant A.
  • A cache key written under Tenant A is not readable under Tenant B.
  • A route outside the tenant middleware group does not leak scoped data.
  • An unauthenticated request does not resolve to a default tenant.

With no upstream test coverage to lean on, your suite is the specification. Run it on every dependency bump, not just on your own changes.

Gotchas and Tips

find() on another tenant's record returns null, not an exception. That is the correct behaviour of a global scope, but it presents as a 404 and reads like "the record is missing." When debugging missing data, check two things before anything else: is the global scope actually registered on the model, and did the tenant resolve? Both are invisible in the response.

Tenant leakage in queues is the top failure mode. A job dispatched during a tenant request wakes up with no context: it either writes to the default/global tenant or fails validation deep in the call stack. The compounding hazard is a well-meaning withoutGlobalScopes() inside a job — that turns a scoped write into a cross-tenant write with no error. Treat withoutGlobalScopes in job code as a review flag.

Middleware order is the security boundary. Tenant resolution must run before anything that queries tenant-scoped data: auth guards loading per-tenant users, route model binding, rate limiting, policy checks. If scoping looks broken, the middleware is almost certainly in the wrong group or ordered after binding. Ten minutes of dd() in the middleware beats an afternoon of reading scope internals.

Prefix cache and session keys with the tenant. If the module doesn't do it for you, do it yourself: cache("t{$tenantId}:feature-flags"), or a per-tenant cache store. Cross-tenant cache collisions are invisible locally (one tenant) and surface in production as "why does this customer see that customer's data?" — the worst class of bug to debug after the fact.

Verify publish tags; don't assume them. TomatoPHP modules usually follow <name>-config / <name>-migrations conventions, but a wrong --tag does nothing at all. Use --provider= and confirm the files landed where you expect. "It's configured" plus a stale config file is a confusing state to be in.

Debugging recipe, two moves. (1) Log the resolved tenant at the top of the resolution middleware — nine out of ten tenancy bugs are "the tenant was never set." (2) If a query returns the wrong rows, wrap it:

DB::listen(fn ($q) => logger($q->sql, $q->bindings));

and confirm the tenant_id = ? predicate is actually present. If it isn't, the scope isn't applied; if it is and the results are still wrong, the binding value is wrong.

Diff the migrations directory on every version bump. There is no changelog and no migration notes. git diff between release tags on the migrations folder is your only upgrade guide. Treat a minor version bump on a package that touches tenant scoping as a breaking change until proven otherwise.

After any Filament major upgrade, re-run the adversarial tests before deploying. Filament's query behaviour has changed between majors in ways that can silently disable or double-apply global scopes. That is a cross-tenant data exposure, not a UI regression, and it will not be caught by clicking around staging.

Prefer extension points over patches, always. Config-driven model bindings, published migrations you now own, and your own Filament resources registered alongside the package's are all real paths. Editing vendor/ means the next composer update erases the fix at the exact moment you've forgotten what it did.

Underwrite the maintenance risk consciously. One maintainer, one star, zero dependents, last release March 2024, MIT. That is a fine risk profile for an internal admin tool you have mirrored internally; it is a poor one for the security boundary in front of customer data unless you have read the tenant-resolution middleware yourself and own the test suite that guards it. Mirror the repo, pin the version, and put a named owner on the upgrade decision — "community supported" means your incident response is bounded by how fast you can read PHP.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource