Concept Overview
Actions
The StarterKit does not publish controllers to your application. Instead, backend logic is encapsulated in "Action" classes. During the nubos:init process, actions are published to your application's app/Actions directory.
Action classes perform a single operation and follow a consistent pattern: a public execute() method that receives typed parameters, wraps writes in DB::transaction() when multiple database operations are involved, and dispatches a domain event on completion.
class CreateTeamAction
{
public function execute(User $owner, array $data): Team
{
return DB::transaction(function () use ($owner, $data): Team {
$team = Team::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? Str::slug($data['name']),
'owner_id' => $owner->id,
'personal_team' => $data['personal_team'] ?? false,
]);
$team->users()->attach($owner->id, ['role' => 'owner']);
$owner->update(['current_team_id' => $team->id]);
event(new TeamCreated($team));
return $team;
});
}
}Each organization type generates three actions: Create{Model}Action, Add{Model}MemberAction, and Remove{Model}MemberAction. You are free to customize these classes.
Views / Pages
The StarterKit uses Inertia.js with Vue 3. Pages are published to your resources/js/Pages directory. Routes return Inertia responses:
Route::get('/dashboard', function () {
return inertia('Dashboard');
})->name('dashboard');There is no Blade or Livewire. All frontend rendering happens through Vue single-file components with <script setup lang="ts">.
Middleware
The StarterKit generates middleware that resolves the current organization context from the URL. This approach is session-independent, meaning users can work with multiple organizations in separate browser tabs simultaneously.
For Team and Workspace organizations, SetCurrent{Model} middleware resolves the organization from a route parameter, verifies membership, and stores the context on the request:
class SetCurrentTeam
{
public function handle(Request $request, Closure $next): Response
{
$team = $request->route('team');
if (! $team instanceof Team) {
$team = Team::query()->where('slug', $team)->first();
}
if (! $team) {
abort(404);
}
if (! $request->user()->belongsToTeam($team)) {
abort(403);
}
$request->attributes->set('current_team', $team);
if ($request->user()->current_team_id !== $team->id) {
$request->user()->update(['current_team_id' => $team->id]);
}
return $next($request);
}
}For Tenant organizations, TenantIdentification middleware resolves the tenant from the subdomain instead of a route parameter.
A companion RedirectToCurrent{Model} middleware redirects unauthenticated URL paths to the user's last-used organization.
Traits
Instead of model inheritance, the StarterKit uses traits to add organization capabilities to models.
User model traits are injected automatically by nubos:init:
HasTeams/HasWorkspaces-- Addsteams()/workspaces(),ownedTeams(),currentTeam(), andbelongsToTeam()relationships and methods to the User model.BelongsToTenant-- Addstenants(),currentTenant(), andbelongsToTenant()to the User model.
Organization model traits are applied to organization models when they run under a tenant:
TenantScope-- A global scope that automatically filters all queries by the current tenant'stenant_idand auto-setstenant_idon new records. Applied to Team/Workspace models when they exist as sub-organizations within a tenant.
Authorization
The StarterKit ships with a database-backed roles and permissions system. Roles are assigned to users with an optional scope (the organization, workspace, or team). Permissions are attached to roles. All authorization checks use Laravel's native Gate and Policy infrastructure.
A Gate::before callback in AppServiceProvider handles three escalation levels:
- Platform super-admin (
nubos:super-admin) -- bypasses all checks. - Org owner/admin -- full access within their organization's scope.
- Permission match -- checks if the user has the requested permission in any active scope.
The active scopes are resolved by a ScopeResolver service. The default implementation returns an empty array. During nubos:init, it is replaced with a variant-specific implementation that reads the current organization context from the request (e.g. current_tenant, current_workspace, current_team).
// In a controller -- authorization always happens here, never in FormRequests
$this->authorize('members.invite');Policies live in domain folders (App\Policies\Authorization\) and are registered on models via #[UsePolicy] attributes. See the Roles & Permissions documentation for the full reference.
Events
Actions dispatch domain events that you can listen to. Every event uses Dispatchable and SerializesModels, and receives the relevant model(s) as readonly promoted constructor properties:
class TeamCreated
{
use Dispatchable;
use SerializesModels;
public function __construct(
public readonly Team $team,
) {}
}The naming convention is {Model}{Action}:
| Event | Dispatched By |
|---|---|
TeamCreated | CreateTeamAction |
TeamMemberAdded | AddTeamMemberAction |
TeamMemberRemoved | RemoveTeamMemberAction |
The same pattern applies to Workspace and Tenant organizations.
Configuration
The nubos:init command generates a config/nubos.php file that records your choices. This file is read-only after generation -- it serves as a record of which organization type was scaffolded:
return [
'organization_type' => 'team',
'organization_model' => \App\Models\Team::class,
'has_sub_teams' => false,
];For the None organization type, the config is minimal:
return [
'organization_type' => 'none',
'has_sub_teams' => false,
];The configuration varies by organization type and selected options. For tenant organizations it includes additional keys like database_strategy and sub_organization.
