Architecture & project technicals
View on GitHubBackend layered architecture
Controller → Service → Repository, with no exceptions and no shortcuts — a controller never builds an Eloquent query, and a repository never contains business logic or fires an event. Traced below through one real, complete request.
The rule for each layer
- Controller (
app/Http/Controllers/) — validates the request (inline$request->validate([...])or a Form Request class), calls exactly one Service method to do the actual work, and returns aredirect()(mutating routes) or anInertia::render(...)response (page routes). Never touches Eloquent directly, never contains a conditional that represents a business rule. - Service (
app/Services/) — owns business logic and every side effect: writing anActivityLogentry, firing a domainEvent, enforcing an invariant viaValidationException. Calls one or more Repositories for actual data access, and can call other Services (e.g.ProjectMemberServicecallsRoleService). Never returns an HTTP response, never reads$requestdirectly. - Repository (
app/Repositories/) — every Eloquent query in the app lives here:where(), eager-loading, ordering, pagination, aggregation. A repository method does exactly what its name says and nothing else — no business rules, no events, no validation.
Traced through a real request
PATCH /projects/{project}/members/{user} — promoting a member —
end to end:
ProjectMemberController::updateRole()
- $this->authorize('updateMemberRole', $project) [Policy check]
- $request->validate([...]) [shape check]
- $this->projectMemberService->updateRole($project, $user, $role)
│
▼
ProjectMemberService::updateRole()
- assertIsMember() / assertNotOwner() [business rules]
- $this->projectMemberRepository->updateRole(...) [data access]
- $this->roleService->syncSystemRoleForMember(...) [another Service]
- $this->activityLogService->log(...) [side effect]
│
▼
ProjectMemberRepository::updateRole()
- $project->users()->updateExistingPivot($userId, ['role' => $role->value])
│
▼ (back up through Service, back up through Controller)
redirect()->back()->with('success', "...")
Every layer only knows about the layer directly below it — the
Controller never calls a Repository, and the Repository never knows a
Policy exists.
Authorization lives outside all three layers
app/Policies/ is a fourth, cross-cutting concern, not a layer in the
chain above — a Policy is only ever called from a Controller
($this->authorize(...)) or, for computing a plain UI-facing boolean
prop rather than gating a mutating request, directly off a Model
method (Project::hasPermission()/hasPermissionOrTier() — see
../permissions/README.md's architecture
section for exactly when to use which).
Adding a brand-new domain that follows this pattern
If you're adding an entirely new resource (not extending an existing
one — see every other category in documentation/ for that), the
checklist, using Comment as the reference shape (a smaller, complete
example than Issue):
- Migration + Model (
app/Models/) — fillable fields, casts, relationships (belongsTo,hasMany). - Repository (
app/Repositories/) — one method per query shape the feature actually needs (getForIssue(), not a genericfindBy(array $criteria)— see every existing repository for this convention: narrow, purpose-named methods over a generic query builder wrapper). - Service (
app/Services/) — one public method per use case (addComment(),updateComment(),deleteComment()), each calling the Repository and firing whateverEvent/ActivityLogentry the use case implies. - Policy (
app/Policies/) — one method per ability, following thehasPermissionOrTier()/hasPermission()pattern if the resource is project-scoped (see../permissions/01-add-a-new-permission.md). - Controller (
app/Http/Controllers/) — one action per route, each$this->authorize(...)-gated, delegating to exactly one Service call. - Routes (
routes/web.php) — named, grouped with the resource's related routes. - Frontend types + components — a type in
resources/js/types/mirroring the model's shape, then whatever Atoms/Molecules/ Organisms the feature's UI needs (see03-frontend-architecture-and-atomic-design.md).
Tests
Every layer gets its own test file, mirroring the real ones:
tests/Feature/<Domain>ServiceTest.php,
tests/Feature/<Domain>RepositoryTest.php,
tests/Feature/<Domain>ControllerTest.php, and — if the domain has
interesting model-level behavior (a cast, a computed attribute) —
tests/Feature/Models/<Domain>Test.php. See
tests/Feature/CommentServiceTest.php/CommentRepositoryTest.php/
CommentControllerTest.php for the smallest complete real example of
this four-file shape.
