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.
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,
];The configuration varies by organization type and selected options. For tenant organizations it includes additional keys like database_strategy and sub_organization.
