# Backend Architecture (app/)

> Discovery beats enumeration. Service/observer/command lists drift fast — use the commands in the root `CLAUDE.md` "Discovery patterns" table to enumerate. This file documents *invariants*: what to use when, conventions, and patterns that don't change with each new file.

## Directory Layout (top-level only)

```
app/
├── Console/Commands/   Artisan commands (scheduled in routes/console.php)
├── Data/               DTOs (no Eloquent, no business logic)
├── Enums/              PHP 8.1 backed enums (string-backed for DB casting)
├── Exceptions/         Typed domain exceptions — see "Exception Convention" below
├── Helpers/            Global helpers (loaded via composer.json autoload.files)
├── Http/
│   ├── Controllers/    Thin — delegate to services. Settings/ and Webhook/ subfolders.
│   ├── Middleware/     See `bootstrap/app.php` for registration order
│   ├── Requests/       Form Requests — REQUIRED for every mutation endpoint
│   └── Resources/      API resources extending BaseApiResource
├── Jobs/               Queue jobs — implement ShouldQueue. External I/O lives ONLY here.
├── Listeners/          Event listeners (auto-discovered)
├── Models/             Eloquent — every model needs a factory in database/factories/
├── Notifications/      Notification classes
├── Observers/          Registered in AppServiceProvider::boot()
├── Policies/           SitePolicy (ownership), UserPolicy (admin)
├── Providers/          AppServiceProvider, EventServiceProvider
├── Rules/              Custom validation rules
├── Services/           Constructor injection only. No static methods. No facades-as-DI.
└── Support/            Pure utilities (no DB, no HTTP)
```

## Service Routing — When To Use What

| Task | Use | Reason |
|------|-----|--------|
| Generate AI drafts | `OpenAiServiceFactory` | Wraps circuit breaker + key loading; never `new OpenAiService` |
| Get current usage limit | `LimitService::current($key, $user, $site)` | Counts DB records — there are no counter columns |
| Track recommendation ROI | `RoiTrackingService` (baseline) → `RoiCalculationService` (deltas) | Two-phase pattern triggered by `RecommendationObserver` |
| Publish to WordPress | `WpPublishService` → dispatch `PublishDraftToWpJob` | External I/O must run in a job, not request lifecycle |
| Validate AI key + concurrency before generation | `DraftValidationService` | Uses `DB::transaction` + `lockForUpdate` to prevent race |
| Resolve AI model/temp settings | `AiSettingsResolver` | Cascade: site settings → config defaults; clamps to safe ranges |
| Resolve analysis thresholds | `AnalysisSettingsResolver` | Same cascade pattern |
| Run any analysis with status lifecycle | Use `RunsAnalysis` trait (see below) | Standardizes pending → processing → completed/failed |
| Fan-out limit cache invalidation | `LimitCacheService::invalidateCount($key, $user)` | Call from observers when affected counts change |

**Billing tier key → display name** (use keys in code, display names in UI only):

| Internal key | Display name |
|--------------|--------------|
| `free` | Starter |
| `pro` | Professional |
| `team` | Agency |
| `enterprise` | Enterprise |

**Two services that look similar but are different:**
- `LimitService` = RankWiz feature usage caps (sites, drafts, briefs, etc.)
- `PlanLimitService` = Stripe subscription tier entitlements

## Service Conventions

- **Constructor injection only.** No static factory methods, no service-locator patterns.
- **External API calls live in Jobs**, never in the request lifecycle. Controllers/services dispatch jobs; jobs do HTTP.
- **Throw typed exceptions** from `app/Exceptions/` — never `throw new Exception(...)`.
- **Run-pattern services** use `App\Services\Concerns\RunsAnalysis` (see below).
- **Cascade resolvers** (settings/limits) read from: site override → user override → config default. Always clamp to safe ranges.

## RunsAnalysis Trait

Standardizes the lifecycle for any "Run" model (AnalysisRun, CannibalizationRun, PageDecayRun, TopicClusterRun, etc.).

```php
use App\Services\Concerns\RunsAnalysis;

class MyAnalysisService
{
    use RunsAnalysis;

    public function analyze(MyRun $run): MyRun
    {
        return $this->executeRun($run, function ($run) {
            // core logic; return summary array
            return ['metric1' => $value1];
            // duration_ms is added automatically
        });
    }
}
```

**The trait handles:** setting `JobStatus::Processing` + `started_at`, timing the closure, on success → `Completed` + `completed_at` + `summary`, on exception → `Failed` + `error` + summary with `error_type`, then re-throws.

**Run model requirements:** columns `status` (JobStatus enum cast), `started_at`, `completed_at`, `summary` (JSON), `error` (text nullable).

## Controller Conventions

- Thin — controllers delegate; services own logic.
- Every mutation route uses a **Form Request** (no inline `$request->validate()`).
- Site-scoped controllers receive `Site $site` via route model binding + `can:view,site` middleware.
- Inertia responses: `Inertia::render('Domain/Page', $props)`. The `.tsx` file MUST exist (contract test enforces).
- Multiple controllers rendering the same page MUST emit identical top-level prop keys.
- Settings controllers live in `app/Http/Controllers/Settings/` (subfolder, not flat).
- Flash messages: `redirect()->back()->with('success'|'error', ...)` — surfaced via `useFlashToasts()`.
- Never expose internal exceptions to users — catch typed exceptions, convert to flash or 4xx.

## Model Conventions

- `$fillable` whitelist on every model — never `$guarded = []`.
- Casts:
  - `datetime` for date columns
  - `array` for JSON columns
  - `encrypted` for OAuth tokens, API keys, HMAC secrets (see `database/CLAUDE.md` for the full list)
  - Enum class for enum columns: `'status' => JobStatus::class`
- **Eager load before access** — lazy loading is globally disabled and throws in dev/test.
- Soft deletes are an exception, not the default. Models using soft deletes: `Site`, `User`, `BlogPost`, `WebhookEndpoint`, `ChangelogEntry`. All others use hard deletes.

## Job Conventions

- Implement `ShouldQueue`.
- Set `$tries` (NOT `$retries` — common bug) and `$backoff` array (e.g., `[30, 120, 300]` seconds).
- Catch typed exceptions, log with structured context (`user_id`, `site_id`, `action`, `duration_ms`), then re-throw to trigger retry.
- Check limits via `LimitService` before doing work.
- **All external API calls happen here.** Never in controllers, never in services called from a request.
- For DI on a job that internally instantiates a service, prefer `app()->has(Foo::class) ? app(Foo::class) : new Foo(...)` so tests can substitute via `app()->instance(Foo::class, $mock)`.

## Observer Conventions

- Registered in `AppServiceProvider::boot()`.
- Primary uses: cache invalidation, ROI baseline capture, downstream job dispatch on status transitions.
- When adding a model whose count is checked by `LimitService`, add an observer that calls `LimitCacheService::invalidateCount($key, $user)` on `created`/`deleted`/`forceDeleted`/`restored`.
- Observers should not contain business logic — dispatch jobs for non-trivial side effects.

## Exception Convention

Custom exceptions live in `app/Exceptions/`. They group by domain (`Gsc*`, `Wp*`, `Ai*`, `LimitExceeded*`, `CircuitBreakerOpen*`, `PromptInjection*`, `SerpApi*`, `ContentRollback*`, `ConcurrentOperation*`, etc.).

**Pattern:**
- Service throws typed exception with structured context.
- Controller catches and converts to user-safe flash message or HTTP 4xx.
- Job catches, logs with context, re-throws for queue retry.
- **Never** expose stack traces, SQL, or internal state to end users.

To enumerate current exceptions: `ls app/Exceptions/`.

## Middleware

Registered in `bootstrap/app.php` (Laravel 12 — NOT `app/Http/Kernel.php`).

**Stable invariants:**
- `HandleInertiaRequests` shares `auth`, `flash`, `features`, `sites`, `limits`, `ai_defaults`, `active_jobs`, `notifications_unread_count` to every Inertia page.
- `SecurityHeaders` sets HSTS, X-Frame-Options, CSP — do not remove.
- `VerifyWebhookSignature` validates HMAC on incoming webhook routes.
- `ResolveCustomDomain` maps custom branded domains → tenant.
- Site-scoped routes use `can:view,site` (Laravel auth middleware backed by `SitePolicy`).
- Feature-gated routes use `feature-gate:flag_name` middleware **OR** boot-time `if (config('features.X.enabled'))` blocks (see test gotcha in `tests/CLAUDE.md`).

To list all middleware: `ls app/Http/Middleware/`.

## Custom Validation Rules

- `NoPrivateIp` — SSRF prevention. Blocks private IPv4/IPv6, reserved hostnames (`localhost`, `*.local`, `metadata.google.internal`), DNS rebinding. Resolves hostnames and checks all returned IPs.

**Apply `NoPrivateIp` to every Form Request that accepts a URL.**

## Adding a New Feature (workflow)

1. Create model in `app/Models/` with `$fillable`.
2. Create factory in `database/factories/` — every model needs one.
3. Create migration in `database/migrations/` — nullable columns on existing tables; cascading FKs with `->index()`.
4. Create Form Request in `app/Http/Requests/` for any mutation.
5. Create service in `app/Services/` — constructor injection, throws typed exceptions.
6. Create controller — thin, delegates to service, uses Form Request.
7. Add route in `routes/web.php` with appropriate middleware (`auth`, `can:view,site` if site-scoped, `feature-gate:X` if behind a flag).
8. Run `php artisan ziggy:generate` and add the new route to `resources/js/test/setup.ts` `mockRoutes`.
9. If the feature has a UI: create `.tsx` in `resources/js/Pages/` BEFORE the controller (contract test).
10. Write tests **first** for services/controllers/jobs; test-after is acceptable for UI components.
11. If external API: add typed exception, put HTTP calls in a Job, add circuit breaker if it's a paid/rate-limited API.
