Laravel 13 + Filament v5 + Livewire v4 invoicing app. Modular architecture via nwidart/laravel-modules v13. PHP 8.3+ (dev box: 8.4.23), Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to Company.
Resolved versions (from composer.lock):
laravel/framework13.15.0 (PHP ^8.3)filament/filament5.6.7 (+ 9 sub-packages @ 5.6.7)livewire/livewire4.3.1nwidart/laravel-modules13.0.0spatie/laravel-permission8.0.0danharrin/livewire-rate-limiting2.2.0
app/ # Thin root (Http/Controllers/ empty, Providers/AppServiceProvider.php)
Modules/ # All business logic lives here (8 modules)
bootstrap/ # app.php
config/ # modules.php, ip.php, filament.php, database.php, app.php
database/migrations/ # Only the exports table — all other migrations are per-module
database/seeders/ # DatabaseSeeder.php — seeds ivplv2 company at id=22
resources/css/filament/ # Per-panel Vite themes (company/nord.css, admin/*, user/*)
routes/ # Empty — routing is handled entirely by Filament panel providers
Modules/Core/Providers/ # All three Filament panel providers live here
| Module | Key models |
|---|---|
| Core | User, Company, CompanyUser, TaxRate, Numbering, EmailTemplate, CustomField, Upload, Note, AuditLog, Setting, MailQueue |
| Clients | Relation (table: relations), Contact, Address, Communication |
| Invoices | Invoice, InvoiceItem, RecurringInvoice |
| Quotes | Quote, QuoteItem |
| Payments | Payment |
| Products | Product, ProductUnit, ProductCategory |
| Projects | Project, Task |
| Expenses | Expense, ExpenseCategory, ExpenseItem |
Every module follows:
Modules/<Name>/
Database/{Factories,Migrations,Seeders}/
Models/
Filament/{Admin,Company}/Resources/<Resource>/{Pages,Tables,Schemas}/
Services/
Enums/
Events/ Listeners/ Observers/
Traits/ Helpers/
Http/{Controllers,Requests,Middleware}/
Providers/
Tests/{Unit,Feature}/
Three providers at Modules/Core/Providers/:
| Provider | Panel ID | URL path | Tenanted | Access roles |
|---|---|---|---|---|
| AdminPanelProvider | admin |
/admin |
No | SUPER_ADMIN, ADMIN, ASSIST |
| CompanyPanelProvider | company |
`` (root, default) | Yes — Company by search_code |
CUSTOMER_ADMIN, CUSTOMER |
| UserPanelProvider | user |
/user |
No | (minimal, for future use) |
->tenant(Company::class, slugAttribute: 'search_code')
->tenantMiddleware([
SetTenantFromQueryString::class, // reads ?tenant=<search_code>, sets session + Filament tenant
ConfigureTenant::class, // resolves tenant from route/query/session/user fallback
EnsureUserCanAccessCompany::class,// enforces access; 403 if regular user not in company_user
], isPersistent: true)URLs look like /ivplv2/dashboard (search_code as route segment).
- Login page:
Modules/Core/Filament/Pages/Auth/Login.php- Rate-limited (5 attempts), checks
$user->is_active, regenerates session - Returns
app(LoginResponse::class)
- Rate-limited (5 attempts), checks
Modules/Core/Filament/Responses/LoginResponse.php- Elevated users (super_admin/admin/assist): find company globally →
filament()->setTenant() - Regular users: find company from
$user->companies()→ set session + Filament tenant - Redirects to
route('filament.company.pages.dashboard', ['tenant' => Str::lower($company->search_code)])
- Elevated users (super_admin/admin/assist): find company globally →
- Company panel middleware chain then enforces access on every subsequent request.
- Logout:
Modules/Core/Filament/Responses/LogoutResponse.php→ redirects tofilament.company.auth.login
// User ↔ Company — BelongsToMany via company_user pivot (id, company_id, user_id — no timestamps)
$user->companies() // returns Collection<Company>
$company->users()
// All business models are scoped to a company via BelongsToCompany trait
// Trait auto-injects company_id on create (from Filament tenant / session / user's first company)
// Trait adds global scope — queries are always filtered by current company// Modules/Core/Enums/UserRole.php
UserRole::SUPER_ADMIN = 'super_admin' // full system access
UserRole::ADMIN = 'admin'
UserRole::ASSIST = 'assist'
UserRole::CUSTOMER_ADMIN = 'client_admin' // company-scoped admin
UserRole::CUSTOMER = 'client'
UserRole::elevated() // ['super_admin', 'admin', 'assist'] — can access any panel
UserRole::nonAdmin() // ['client_admin', 'client'] — company panel only
// Usage
$user->hasRole(UserRole::SUPER_ADMIN->value)
$user->assignRole(UserRole::ADMIN->value)
$user->isSuperAdmin() // shorthandThis project uses PHPUnit exclusively. Pest is NOT installed and must NOT be used. Never write
it(),test(),describe(),uses(), or Pestexpect()chains. All tests are class-based, extend one of the base classes below, and use#[Test]attributes.
| Class | Extends | Sets up | Use for |
|---|---|---|---|
AbstractAdminPanelTestCase |
TestCase + RefreshDatabase | Company factory + superAdmin user; panel='admin'; Carbon frozen 2026-01-01 | Admin panel Livewire tests |
AbstractCompanyPanelTestCase |
TestCase + RefreshDatabase | User with company (search_code='IVPLV2'); Filament tenant set quietly; session current_company_id | Company panel Livewire tests |
AbstractTestCase |
TestCase (no RefreshDatabase) | Nothing | Pure unit tests |
All live at Modules/Core/Tests/.
// Company panel test
class FooTest extends AbstractCompanyPanelTestCase {
public function it_does_something(): void {
$response = Livewire::actingAs($this->user)
->test(ListFoo::class, ['tenant' => Str::lower($this->company->search_code)])
->assertSuccessful();
}
// Or use the helper:
$this->testLivewire(ListFoo::class)->assertSuccessful();
}
// Admin panel test
class BarTest extends AbstractAdminPanelTestCase {
public function it_does_something(): void {
Livewire::actingAs($this->superAdmin())->test(ListBar::class)->assertSuccessful();
}
}
// Create a role in tests (Spatie needs DB record)
Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']);
$user->assignRole(UserRole::SUPER_ADMIN->value);// User with a specific company attached
User::factory()->withCompany(['search_code' => 'IVPLV2', 'name' => '...'])->create()
// Company-scoped model — pass company via ->for() or via session/tenant
Invoice::factory()->for($company)->create()
// Active user
User::factory()->create(['is_active' => true, 'email_verified_at' => now()])<!-- phpunit.xml -->
<testsuite name="Unit"> <directory>Modules/*/Tests/Unit</directory> </testsuite>
<testsuite name="Feature"><directory>Modules/*/Tests/Feature</directory></testsuite>Tests need a DB. Production CI uses MariaDB 11. For local dev without MySQL, set in .env.testing:
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
Phase labels (Arrange, Act, Assert) inside test methods must use block comments. Line comments (//) are prohibited for phase labels.
/* Arrange */
...
/* Act */
...
/* Assert */
/* Act & Assert */ ← combined phase label, same ruleNever write // Arrange, // Act, or // Assert.
All model lifecycle business logic (duplicate checks, validation, side effects on save/create/update/delete) goes in Observers/, not inline booted() methods on the model.
- Create
Modules/<Name>/Observers/<Model>Observer.phpextendingModules\Core\Observers\AbstractObserver - Register in the module's ServiceProvider
boot():Model::observe(ModelObserver::class); - Always use
Model::withoutGlobalScopes()inside observers — global scopes (e.g.BelongsToCompany) may not be active in all contexts (unauthenticated, queued jobs, tests) - Do not add
static::creating()/static::saving()callbacks insidebooted()on the model — the Observer handles this
// Modules/Invoices/Observers/InvoiceObserver.php
class InvoiceObserver extends AbstractObserver {
public function saving(Invoice $invoice): void {
// saving fires before both creating and updating — covers all writes
}
}
// Modules/Invoices/Providers/InvoicesServiceProvider.php boot()
Invoice::observe(InvoiceObserver::class);Company::$primaryKey=id(standard); URL slug issearch_code(10 chars, unique, e.g.ivplv2)User::$timestamps = false— no created_at/updated_at on users tableRelationmodel → tablerelations(notcustomers, notclients)- Soft deletes on: Invoice, Quote (and their items)
BelongsToCompanytrait → addscompany()BelongsTo,scopeForCompany(), and global scope
All services extend Modules\Core\Services\BaseService:
// BaseService gives you:
$this->create(array $data): Model
$this->find($id): Model
$this->update(array $input, $model): Model
$this->delete($id): bool
$this->paginate($perPage): LengthAwarePaginator
$this->getCompanyId(): ?int // resolves from Filament/session/userNo DTO layer — services accept arrays and return Eloquent models.
database/seeders/DatabaseSeeder.php seeds:
ivplv2company hard-coded atid=22,search_code='ivplv2',name='InvoicePlane Corporation'- 5 additional random companies
- Spatie roles for all UserRole values
- One user per role, all attached to ivplv2
| Symptom | Check |
|---|---|
| Company-scoped query returns nothing | session('current_company_id') set? Filament tenant set? |
| 403 on company panel route | User in company_user pivot for that company? |
| Redirect after login goes to wrong company | LoginResponse::DEFAULT_COMPANY_CODE constant and whereRaw('LOWER(search_code) = ?') query |
| Factory creates model with null company_id | Pass ->for($company) or set Filament tenant before creation |
| Livewire test fails with "No tenant" | Call Filament::setTenant($company) in test setUp |
| Test role check fails | Create Role DB record first with Role::query()->firstOrCreate(...) |
- The default company is always
ivplv2(search_code, lowercase in URLs) - Panel routes are named
filament.<panel-id>.pages.<slug>andfilament.<panel-id>.resources.<resource>.<action> $user->companies()returns the BelongsToMany — call.first(),.attach(),.detach()on itStr::lower($company->search_code)is always the URL tenant parameter- The three tenant middleware classes live at
Modules/Core/Http/Middleware/ - Panel providers live at
Modules/Core/Providers/, notapp/Providers/