This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Laravel 12 API-only SaaS backend (multi-tenant: companies → projects → tasks) with Sanctum auth and Spatie roles/permissions. No frontend framework is wired up — resources/views is the stock Laravel welcome page; the real surface is routes/api.php.
Read PRD.md, MEMORIES.md, and memory/MEMORY.md (plus the files it indexes) at the start of every session, before doing any work. PRD.md is the product spec (reverse-engineered from code — correct it as real intent becomes clear). MEMORIES.md is a running decision log of what changed, why, and what's still open — append to it (don't rewrite history) whenever you make a non-obvious decision or find something worth remembering across sessions.
All persistent Claude memory for this project is stored locally on disk in this repo directory, never in the global cross-session memory directory (~/.claude/projects/.../memory/) — see memory/feedback_local_memory_only.md. Both MEMORIES.md and memory/ are gitignored: they exist only in the local working copy, are never committed or pushed, and are read straight off disk each session regardless of git tracking status. When you'd normally save a user/feedback/project/reference-type memory:
- Write it to
memory/<type>_<slug>.mdusing the same frontmatter convention as the files already there (name,description,metadata.type,metadata.modified). - Add a one-line pointer to it in
memory/MEMORY.md. - Do not create or write to any file under the global
~/.claude/projects/.../memory/path for this project.
MEMORIES.md (repo root) stays the running decision log for code-level changes; memory/ is for the more structured, cross-session user/feedback/project/reference memory types.
composer install && npm install # install deps
php artisan key:generate # after copying .env.example to .env
php artisan migrate # sqlite by default (database/database.sqlite)
composer dev # runs serve + queue:listen + pail + vite concurrently
php artisan serve # API server only
composer test # clears config cache, then runs the suite
php artisan test # same, without the config:clear
php artisan test --filter=testName # single test by name
php artisan test tests/Feature/ExampleTest.php # single file
vendor/bin/pint # code style (Laravel Pint)
npm run build / npm run dev # Vite (Tailwind v4), largely unused by any real viewTest env (phpunit.xml) forces sqlite :memory:, array cache/session/mail, sync queue — tests never touch the dev database.sqlite.
Tenancy model: Company → hasMany User and Project; Project belongsTo Company, hasMany Task; Task belongsTo Project and belongsTo User (as assignedUser, via assigned_to). Task has no company_id of its own — its tenant is reached via task->project->company.
Tenant isolation is manual, not structural. There is no global scope, no BelongsToCompany trait, and no middleware that binds a "current company." Every controller enforces scoping by hand-chaining through the relation, e.g. auth()->user()->company->projects()->findOrFail(...) (app/Http/Controllers/Api/V1/TaskController.php, ProjectController.php). When touching these controllers or adding new ones, follow the same chain-through-company pattern explicitly — nothing else prevents a cross-tenant query.
Authorization: TaskPolicy (app/Policies/TaskPolicy.php) is the only policy. Admins/managers are authorized by task->project->company_id === user->company_id; developers only by task->assigned_to === user->id. It resolves via Laravel's default naming-convention auto-discovery (Task → TaskPolicy) — AuthServiceProvider::$policies is declared but inert because the provider extends the generic Illuminate\Support\ServiceProvider instead of the auth-specific base, and registerPolicies() is never called.
Routes (routes/api.php): everything under Route::prefix('v1'). Public: POST v1/register, POST v1/login. Two auth:sanctum groups: one for POST v1/logout, another for apiResource('projects', ...) and apiResource('tasks', ...). routes/web.php only has / (welcome view) and /admin (gated by Spatie's role:admin middleware alias).
Roles/permissions: Spatie laravel-permission with default config/table names (config/permission.php unmodified, teams => false). User uses HasRoles. Role checks in code use lowercase names (hasRole(['admin','manager']) in TaskPolicy, role:admin in routes/web.php) — keep any new role-related code lowercase to match.
— fixed 2026-08-09.App\Models\RolebugsRoleSeedernow importsSpatie\Permission\Models\Roleand seeds lowercase names (admin,manager,developer) viafirstOrCreate(idempotent, fillsguard_namefrom config).AuthService::register()now calls$user->assignRole('admin')instead of setting arole_idcolumn.User::$fillableno longer listsrole_id(the column never existed).— fixed 2026-08-09. AddedCompany::$fillableemptyprotected $fillable = ['name'];toapp/Models/Company.php.AuthService::register()now completes end-to-end (verified via tinker).— fixed 2026-08-09, same bug class asProject/Task$fillableemptyCompanyabove. Neither model declared$fillable, soProjectController::store()andTaskController::store()threwMassAssignmentExceptionon every call. AddedProject::$fillable = ['name', 'description']andTask::$fillable = ['title', 'description', 'status', 'assigned_to'](project_id/company_id-style relation keys are deliberately excluded — set via relation, not user input). If you add a new model with acreate()/update()call site, check$fillableexplicitly — this codebase has now hit this exact bug three times.— fixed 2026-08-09. AddedTaskController::assign()had no registered routeRoute::post('tasks/{task}/assign', ...)inroutes/api.php(apiResourceonly exposes the standard 7 actions).— fixed 2026-08-09. All four now follow the existing patterns:ProjectController@update/destroyandTaskController@index/destroywere unimplemented stubsProject@update/@destroychain throughcompanylikeshow();Task@indexscopes by company and further byassigned_tofor non-admin/manager roles;Task@destroyauthorizes viaTaskPolicy::delete()(previously hardcodedfalse— now mirrorsupdate()'s admin/manager-in-company rule).