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

Saas Laravel Package

savannabits/saas

savannabits/saas is a Laravel package for building SaaS applications, offering scaffolding for plans, subscriptions, teams, and tenant-aware workflows so you can add recurring billing and multi-tenant features to your PHP app faster.

View on GitHub
Deep Wiki
Context7

Getting Started

Add the package and its permission dependency, then publish the scaffolding:

composer require savannabits/saas:^1.2
php artisan vendor:publish --provider="Savannabits\Saas\SaasServiceProvider" --tag=saas-config
php artisan vendor:publish --provider="Savannabits\Saas\SaasServiceProvider" --tag=saas-migrations
php artisan migrate

Where to look first, in this order:

  1. config/saas.php (or the package's equivalent config file) — this is where team/role behaviour, model bindings, and route prefixes are toggled. Read it top to bottom before touching anything else.
  2. The published migrations — they define the team/role/permission table shape. Confirm whether they use integer or UUID keys; that decision propagates into every ownership relation you write later.
  3. The package's User contract/trait — the package almost certainly expects your App\Models\User to use a provided trait or extend a base model. Wire that up before you try to assign a role.
  4. The service provider's route/middleware registrations — check which middleware aliases are added so you don't register duplicates.

First use case: seed the baseline roles, create a team, attach a user, assign a role scoped to that team, and gate one route with can:. If that path works end to end, the install is sound.

// database/seeders/RolesSeeder.php
foreach (['owner', 'admin', 'member'] as $role) {
    Role::findOrCreate($role, 'web');
}

Implementation Patterns

Team-scoped authorization. This is the core of the package and the reason v1.2.0 exists. Laravel Permission v6 requires the teams feature to be explicitly enabled in config/permission.php ('teams' => true) plus a team_foreign_key column on roles and model_has_roles. When you enable it, always resolve the team through the package's current-team resolver, not auth()->user()->currentTeam inline — the resolver is what the middleware and the queued listeners agree on.

setPermissionsTeamId($team->id);
$user->assignRole('admin');   // scoped to $team

Policy + plan gating. Keep authorization and billing separate. Policies answer "may this user do X in this team"; a small service or middleware answers "is this team's plan allowed to do X at all". Composing the two inside a single policy leads to policies that query subscription tables and become untestable.

// app/Policies/ProjectPolicy.php
public function create(User $user, Team $team): bool
{
    return $user->belongsToTeam($team)
        && ($user->hasPermissionTo('projects.create') || $team->planAllows('projects'));
}

Seeder-as-deployment-step. Roles and permissions are reference data, not fixtures. Run Role::findOrCreate / Permission::findOrCreate in a dedicated seeder that is safe to re-run, and invoke it from your deploy script. Never rely on the package's demo seeder in production — it typically creates sample users and teams.

Blade and middleware reuse. Prefer @can / @role in views and can: middleware on routes over manual hasRole() checks in controllers. The former go through the Gate, so a policy change updates the UI and the route guard in one place.

Invitations and ownership transfer. Treat team membership changes as domain events. The package emits the usual Eloquent events on pivot changes; hook listeners for audit logging and cache invalidation rather than scattering logic in controllers.

Queued job caveat pattern. Because permission checks are context-dependent, pass the team ID explicitly into jobs and re-establish context in handle():

public function __construct(public int $teamId) {}

public function handle(): void
{
    setPermissionsTeamId($this->teamId);
    // ... now permission checks behave
}

Gotchas and Tips

The v1.2.0 upgrade is not free. The package release itself is a compatibility bump, but it drags you onto Laravel Permission v6, which requires PHP 8.1+ and Laravel 9+. Check composer why-not spatie/laravel-permission ^6.0 before upgrading; a transitive constraint from another package (Filament, older Jetstream forks) will block it. Also re-publish and diff config/permission.php — v6 changed defaults, particularly around teams and cache keys. Merging blindly will silently disable team scoping.

Permission cache is the number one source of "it works locally" bugs. After seeding roles or changing a user's roles outside a request, call php artisan permission:cache-reset. In deploy scripts this must run after the seeder. In long-running workers (Octane, Horizon), a stale in-memory cache will serve revoked permissions until restart — add the reset to your deploy hook, not just the migration step.

Team context is ambient state. Anything that runs without an authenticated user — console commands, scheduled tasks, model observers fired from a queue — has a null team ID, and permission checks there will quietly return false rather than throwing. If a check "randomly" fails in a job but passes in a controller, this is why. Set the team ID at the top of handle() every time.

Guard names must line up exactly. The package registers roles against a guard (usually web). If your app uses a custom guard or an API guard, hasRole('admin') against the wrong guard returns false with no error. Pin the guard in the seeder with the second argument to findOrCreate and keep it in one constant.

Config caching hides freshly published files. php artisan config:cache during deploy can bake in a saas.php from before you published it. Always config:clear before config:cache in the pipeline.

Extension points, ranked by safety. Prefer (1) publishing and overriding config, (2) listening to package events, (3) overriding views/notifications via vendor:publish --tag=saas-views, and only then (4) subclassing the package's models through config-level class bindings. Direct edits to vendor/ will be silently lost on the next composer update, and this package moves fast enough that you will update it.

Migration ordering with UUIDs. If you adopt UUID team keys, every custom table that references a team needs a matching foreignUuid. Mixing integer and UUID keys across the package tables and your own produces foreign key errors that only appear on fresh installs — always test with migrate:fresh in CI, not just incremental migrate.

Debugging tip. When a gate denies unexpectedly, dump $user->getAllPermissions() after setting the team ID. If it's empty, the problem is team scoping or the cache; if it's populated, the problem is in your policy. That single check splits the search space in half.

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource