Compare commits
7 Commits
d528cf16ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9124ccb8d | |||
| 5c215b4647 | |||
| 532f2ddf99 | |||
| c3a6f6cc6e | |||
| 2fdfc0040c | |||
| 8d74ac74cd | |||
| 60413bdebf |
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: infer-conventions
|
||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Infer Conventions
|
||||
|
||||
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||
|
||||
## Ground Rules (read before you start)
|
||||
|
||||
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||
|
||||
## Process
|
||||
|
||||
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||
|
||||
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||
|
||||
### Step 0: Orient
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
### Step 1: Predefined sweep
|
||||
|
||||
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||
|
||||
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||
|
||||
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||
|
||||
### Step 2: Open-ended pass
|
||||
|
||||
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||
|
||||
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||
|
||||
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||
|
||||
### Step 3: Confirm
|
||||
|
||||
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||
|
||||
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||
|
||||
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||
|
||||
### Step 4: Record
|
||||
|
||||
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||
|
||||
Record this:
|
||||
|
||||
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||
|
||||
Not this:
|
||||
|
||||
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||
|
||||
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||
|
||||
### Step 5: Summarize
|
||||
|
||||
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||
|
||||
## Glob mapping
|
||||
|
||||
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||
|
||||
Examples:
|
||||
|
||||
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||
- Tests: `tests/**`.
|
||||
- Migrations and database: `database/migrations/**`.
|
||||
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||
|
||||
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Detection Checklist
|
||||
|
||||
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||
|
||||
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||
|
||||
---
|
||||
|
||||
## A. Validation & HTTP input
|
||||
|
||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||
|
||||
## B. Controllers & routing
|
||||
|
||||
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||
|
||||
## C. Authorization
|
||||
|
||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||
|
||||
## D. Eloquent & models
|
||||
|
||||
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||
|
||||
## E. Architecture & organization
|
||||
|
||||
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||
24. Decoupling: events + listeners vs direct service calls.
|
||||
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||
27. Enums: backed vs pure; case naming; where they live.
|
||||
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
## G. Database & migrations
|
||||
|
||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||
- Hint: grep `function down` vs the migration count.
|
||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||
|
||||
## H. Testing
|
||||
|
||||
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||
- Hint: grep those trait names in `tests/`.
|
||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||
|
||||
## I. Responses & API resources
|
||||
|
||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||
|
||||
## J. Strings, collections & dates
|
||||
|
||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||
|
||||
---
|
||||
|
||||
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ Correct:
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: infer-conventions
|
||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Infer Conventions
|
||||
|
||||
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||
|
||||
## Ground Rules (read before you start)
|
||||
|
||||
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||
|
||||
## Process
|
||||
|
||||
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||
|
||||
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||
|
||||
### Step 0: Orient
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
### Step 1: Predefined sweep
|
||||
|
||||
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||
|
||||
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||
|
||||
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||
|
||||
### Step 2: Open-ended pass
|
||||
|
||||
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||
|
||||
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||
|
||||
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||
|
||||
### Step 3: Confirm
|
||||
|
||||
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||
|
||||
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||
|
||||
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||
|
||||
### Step 4: Record
|
||||
|
||||
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||
|
||||
Record this:
|
||||
|
||||
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||
|
||||
Not this:
|
||||
|
||||
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||
|
||||
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||
|
||||
### Step 5: Summarize
|
||||
|
||||
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||
|
||||
## Glob mapping
|
||||
|
||||
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||
|
||||
Examples:
|
||||
|
||||
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||
- Tests: `tests/**`.
|
||||
- Migrations and database: `database/migrations/**`.
|
||||
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||
|
||||
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Detection Checklist
|
||||
|
||||
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||
|
||||
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||
|
||||
---
|
||||
|
||||
## A. Validation & HTTP input
|
||||
|
||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||
|
||||
## B. Controllers & routing
|
||||
|
||||
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||
|
||||
## C. Authorization
|
||||
|
||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||
|
||||
## D. Eloquent & models
|
||||
|
||||
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||
|
||||
## E. Architecture & organization
|
||||
|
||||
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||
24. Decoupling: events + listeners vs direct service calls.
|
||||
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||
27. Enums: backed vs pure; case naming; where they live.
|
||||
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
## G. Database & migrations
|
||||
|
||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||
- Hint: grep `function down` vs the migration count.
|
||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||
|
||||
## H. Testing
|
||||
|
||||
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||
- Hint: grep those trait names in `tests/`.
|
||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||
|
||||
## I. Responses & API resources
|
||||
|
||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||
|
||||
## J. Strings, collections & dates
|
||||
|
||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||
|
||||
---
|
||||
|
||||
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ Correct:
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: infer-conventions
|
||||
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Infer Conventions
|
||||
|
||||
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||
|
||||
## Ground Rules (read before you start)
|
||||
|
||||
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||
|
||||
## Process
|
||||
|
||||
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||
|
||||
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||
|
||||
### Step 0: Orient
|
||||
|
||||
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||
|
||||
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||
|
||||
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||
|
||||
### Step 1: Predefined sweep
|
||||
|
||||
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||
|
||||
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||
|
||||
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||
|
||||
### Step 2: Open-ended pass
|
||||
|
||||
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||
|
||||
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||
|
||||
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||
|
||||
### Step 3: Confirm
|
||||
|
||||
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||
|
||||
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||
|
||||
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||
|
||||
### Step 4: Record
|
||||
|
||||
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||
|
||||
Record this:
|
||||
|
||||
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||
|
||||
Not this:
|
||||
|
||||
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||
|
||||
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||
|
||||
### Step 5: Summarize
|
||||
|
||||
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||
|
||||
## Glob mapping
|
||||
|
||||
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||
|
||||
Examples:
|
||||
|
||||
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||
- Tests: `tests/**`.
|
||||
- Migrations and database: `database/migrations/**`.
|
||||
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||
|
||||
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Detection Checklist
|
||||
|
||||
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||
|
||||
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||
|
||||
---
|
||||
|
||||
## A. Validation & HTTP input
|
||||
|
||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||
|
||||
## B. Controllers & routing
|
||||
|
||||
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||
|
||||
## C. Authorization
|
||||
|
||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||
|
||||
## D. Eloquent & models
|
||||
|
||||
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||
|
||||
## E. Architecture & organization
|
||||
|
||||
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||
24. Decoupling: events + listeners vs direct service calls.
|
||||
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||
27. Enums: backed vs pure; case naming; where they live.
|
||||
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||
|
||||
## F. Frontend & views
|
||||
|
||||
This app ships a frontend stack, so the items below apply.
|
||||
|
||||
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
|
||||
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
|
||||
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||
|
||||
## G. Database & migrations
|
||||
|
||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||
- Hint: grep `function down` vs the migration count.
|
||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||
|
||||
## H. Testing
|
||||
|
||||
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||
- Hint: grep those trait names in `tests/`.
|
||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||
|
||||
## I. Responses & API resources
|
||||
|
||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||
|
||||
## J. Strings, collections & dates
|
||||
|
||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||
|
||||
---
|
||||
|
||||
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ Correct:
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
|
||||
@@ -61,6 +61,9 @@ KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
KBZ_MERCHANT_KEY=
|
||||
KBZ_BASE_URL=
|
||||
KBZ_CREATE_ORDER_URL=
|
||||
KBZ_QUERY_ORDER_URL=
|
||||
KBZ_REFUND_ORDER_URL=
|
||||
KBZ_NOTIFY_URL=
|
||||
KBZ_CERT_PATH=
|
||||
KBZ_CERT_KEY_PATH=
|
||||
@@ -83,3 +86,6 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FASTAPI_AGENT_JWT_SECRET=
|
||||
FASTAPI_AGENT_JWT_ALGORITHM=HS256
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: PHP Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
php-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_DB: testing
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
extensions: mbstring, bcmath, intl, gd, zip, pdo, pdo_pgsql, redis, pcntl
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Copy .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend assets
|
||||
run: npm run build
|
||||
|
||||
- name: Generate app key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ''
|
||||
run: php artisan test --compact
|
||||
@@ -7,22 +7,11 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
- php - 8.5
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
@@ -81,6 +70,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
|
||||
@@ -7,22 +7,11 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
- php - 8.5
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
@@ -81,6 +70,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Booking\Http\Controllers\BookingController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
|
||||
Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index');
|
||||
Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show');
|
||||
Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store');
|
||||
|
||||
@@ -7,10 +7,29 @@ namespace Modules\Booking\Enums;
|
||||
*/
|
||||
enum BookingChannel: string
|
||||
{
|
||||
case MiniApp = 'mini_app';
|
||||
case MiniApp = 'kbz_miniapp';
|
||||
case Android = 'android';
|
||||
case Ios = 'ios';
|
||||
case Web = 'web';
|
||||
case Agent = 'agent';
|
||||
case Admin = 'admin';
|
||||
|
||||
/**
|
||||
* Resolve the client's channel from its `Device-Type` header, defaulting
|
||||
* to MiniApp when the header is missing or unrecognized. Agent/Admin are
|
||||
* deliberately excluded from what a header can select — those two are
|
||||
* derived from how the request authenticated (FastAPI JWT, Filament),
|
||||
* never a client-supplied value, so a customer can't spoof one via the
|
||||
* header.
|
||||
*/
|
||||
public static function fromDeviceTypeHeader(?string $deviceType): self
|
||||
{
|
||||
$channel = self::tryFrom((string) $deviceType);
|
||||
|
||||
if ($channel === null || in_array($channel, [self::Agent, self::Admin], true)) {
|
||||
return self::MiniApp;
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +99,12 @@ class BookingInfolist
|
||||
// Payment/Refund Filament resources (T5.13), this is just a
|
||||
// quick-glance summary from the booking side.
|
||||
Section::make('Payments')
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
RepeatableEntry::make('payments')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(6)
|
||||
Grid::make(8)
|
||||
->schema([
|
||||
TextEntry::make('gateway')->badge(),
|
||||
TextEntry::make('status')
|
||||
@@ -116,6 +117,7 @@ class BookingInfolist
|
||||
TextEntry::make('amount')->numeric(2),
|
||||
TextEntry::make('currency'),
|
||||
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'),
|
||||
TextEntry::make('gateway_payload.mm_order_id')->label('Transaction ID')->placeholder('—')->columnSpan(2),
|
||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
]),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Dispatch-facing snapshot of today's trips — "today" means travel_date, not
|
||||
* created_at, since this is what staff care about when assigning
|
||||
* drivers/vehicles (domain.md §5a), not how many bookings were made today.
|
||||
*/
|
||||
class BookingsTodayWidget extends BaseWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
$today = Booking::query()->whereDate('travel_date', today());
|
||||
|
||||
$confirmedToday = (clone $today)->where('status', BookingStatus::Confirmed)->count();
|
||||
$pendingToday = (clone $today)->where('status', BookingStatus::PendingPayment)->count();
|
||||
|
||||
return [
|
||||
Stat::make('Trips Today', (clone $today)->count())
|
||||
->description('Bookings scheduled for today')
|
||||
->color('primary'),
|
||||
Stat::make('Confirmed', $confirmedToday)
|
||||
->description('Paid & ready for driver assignment')
|
||||
->color('success'),
|
||||
Stat::make('Awaiting Payment', $pendingToday)
|
||||
->description('Still pending_payment')
|
||||
->color($pendingToday > 0 ? 'warning' : 'gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Widgets;
|
||||
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class RecentBookingsTableWidget extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Recent Bookings')
|
||||
->query(
|
||||
Booking::query()
|
||||
->with(['route.fromDestination', 'route.toDestination'])
|
||||
->latest('created_at')
|
||||
->limit(10),
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->since(),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,21 @@ class BookingController extends Controller
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Booking::class);
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
$bookings = Booking::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
$query = Booking::query();
|
||||
|
||||
if ($openid !== null) {
|
||||
// FastAPI agent (JWT auth, no Laravel user) — scoped to the
|
||||
// verified token's own openid, never a client-supplied value,
|
||||
// so one agent session can't list another customer's bookings.
|
||||
$query->where('openid', $openid);
|
||||
} else {
|
||||
Gate::authorize('viewAny', Booking::class);
|
||||
$query->where('user_id', $request->user()->id);
|
||||
}
|
||||
|
||||
$bookings = $query
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
@@ -42,9 +53,15 @@ class BookingController extends Controller
|
||||
return BookingResource::collection($bookings);
|
||||
}
|
||||
|
||||
public function show(Booking $booking): BookingResource
|
||||
public function show(Request $request, Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('view', $booking);
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid !== null) {
|
||||
abort_if($booking->openid !== $openid, 404);
|
||||
} else {
|
||||
Gate::authorize('view', $booking);
|
||||
}
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
@@ -52,6 +69,11 @@ class BookingController extends Controller
|
||||
public function store(StoreBookingRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('create', Booking::class);
|
||||
}
|
||||
|
||||
$selections = array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
@@ -61,6 +83,14 @@ class BookingController extends Controller
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
// The agent's own auth path always wins over anything a header could
|
||||
// claim; customer channels come from Device-Type, not a
|
||||
// client-supplied body field (BookingChannel::fromDeviceTypeHeader
|
||||
// already refuses to hand back Agent/Admin from a header value).
|
||||
$channel = $openid !== null
|
||||
? BookingChannel::Agent
|
||||
: BookingChannel::fromDeviceTypeHeader($request->header('Device-Type'));
|
||||
|
||||
$booking = $this->createBookingAction->handle(new CreateBookingData(
|
||||
evRouteId: $validated['ev_route_id'],
|
||||
departureTimeSlotId: $validated['departure_time_slot_id'],
|
||||
@@ -70,11 +100,12 @@ class BookingController extends Controller
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: isset($validated['created_by_channel'])
|
||||
? BookingChannel::from($validated['created_by_channel'])
|
||||
: BookingChannel::MiniApp,
|
||||
createdByChannel: $channel,
|
||||
// A verified FastAPI JWT's own openid always wins over a
|
||||
// client-supplied one — a request can never claim a different
|
||||
// customer's identity than its own token proves.
|
||||
userId: $request->user()?->id,
|
||||
openid: $validated['openid'] ?? null,
|
||||
openid: $openid ?? $validated['openid'] ?? null,
|
||||
pickupLat: $validated['pickup_lat'] ?? null,
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Modules\Booking\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
@@ -41,11 +40,8 @@ class StoreBookingRequest extends FormRequest
|
||||
'dropoff_address' => ['required', 'string', 'max:500'],
|
||||
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'openid' => ['nullable', 'string', 'max:255'],
|
||||
'is_round_trip' => ['sometimes', 'boolean'],
|
||||
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
|
||||
// Admin-created bookings go through the Filament resource (T4.7), not this API.
|
||||
'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
@@ -176,3 +177,47 @@ test('shape validation rejects an empty selections array', function () {
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['selections']);
|
||||
});
|
||||
|
||||
test('created_by_channel defaults to kbz_miniapp when no Device-Type header is sent', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
|
||||
});
|
||||
|
||||
test('created_by_channel is taken from the Device-Type header', function (string $deviceType, BookingChannel $expected) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->withHeader('Device-Type', $deviceType)
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', $expected->value);
|
||||
})->with([
|
||||
'android' => ['android', BookingChannel::Android],
|
||||
'ios' => ['ios', BookingChannel::Ios],
|
||||
'web' => ['web', BookingChannel::Web],
|
||||
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
|
||||
]);
|
||||
|
||||
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->withHeader('Device-Type', $deviceType)
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
|
||||
})->with([
|
||||
'agent' => ['agent'],
|
||||
'admin' => ['admin'],
|
||||
'unrecognized value' => ['smart-fridge'],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Widgets\BookingsTodayWidget;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
test('it counts todays trips by status, ignoring other days', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
|
||||
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::PendingPayment]);
|
||||
Booking::factory()->create(['travel_date' => today()->addDay(), 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(BookingsTodayWidget::class)
|
||||
->assertOk()
|
||||
->assertSee('Trips Today')
|
||||
->assertSee('2')
|
||||
->assertSee('Confirmed')
|
||||
->assertSee('Awaiting Payment');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.fastapi_agent.jwt_secret' => 'test-fastapi-agent-secret-0123456789ABCDEF']);
|
||||
config(['services.fastapi_agent.jwt_algorithm' => 'HS256']);
|
||||
});
|
||||
|
||||
function fastApiAgentToken(string $openid): string
|
||||
{
|
||||
return JWT::encode([
|
||||
'sub' => $openid,
|
||||
'iat' => time(),
|
||||
'exp' => time() + 3600,
|
||||
], 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256');
|
||||
}
|
||||
|
||||
test('a FastAPI JWT booking is stored against the verified openid, ignoring a spoofed body value', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'price' => '15000.00',
|
||||
]);
|
||||
|
||||
$token = fastApiAgentToken('real-customer-openid');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->withHeader('Device-Type', 'android') // the agent's own channel always wins, ignored here.
|
||||
->postJson('/api/v1/bookings', [
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'selections' => [['vehicle_option' => 'back_seat', 'passenger_count' => 1]],
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
'pickup_address' => '123 Pickup St',
|
||||
'dropoff_address' => '456 Dropoff Ave',
|
||||
'openid' => 'spoofed-openid',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$booking = Booking::sole();
|
||||
expect($booking->openid)->toBe('real-customer-openid')
|
||||
->and($booking->user_id)->toBeNull()
|
||||
->and($booking->created_by_channel)->toBe(BookingChannel::Agent);
|
||||
});
|
||||
|
||||
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
||||
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
||||
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $mine->id);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/v1/bookings/{$mine->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $mine->id);
|
||||
});
|
||||
|
||||
test('a FastAPI JWT gets a 404 for a booking belonging to a different openid', function () {
|
||||
$someoneElses = Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/v1/bookings/{$someoneElses->booking_ref}")
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Filament\Widgets\RecentBookingsTableWidget;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
test('it lists the most recently created bookings', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$older = Booking::factory()->create(['created_at' => now()->subDay()]);
|
||||
$newer = Booking::factory()->create(['created_at' => now()]);
|
||||
|
||||
Livewire::test(RecentBookingsTableWidget::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords([$newer, $older]);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Modules\Catalog\Http\Controllers\DestinationController;
|
||||
use Modules\Catalog\Http\Controllers\EvCompanyController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index');
|
||||
Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Catalog\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Catalog\Http\Resources\DestinationResource;
|
||||
@@ -9,10 +10,23 @@ use Modules\Catalog\Models\Destination;
|
||||
|
||||
class DestinationController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$terms = array_filter(array_map(
|
||||
trim(...),
|
||||
explode(',', (string) $request->string('search')),
|
||||
));
|
||||
|
||||
return DestinationResource::collection(
|
||||
Destination::query()->where('is_active', true)->get()
|
||||
Destination::query()
|
||||
->where('is_active', true)
|
||||
->when($terms !== [], fn ($query) => $query->where(function ($query) use ($terms) {
|
||||
foreach ($terms as $term) {
|
||||
$query->orWhere('name', 'ilike', "%{$term}%")
|
||||
->orWhere('mm_name', 'ilike', "%{$term}%");
|
||||
}
|
||||
}))
|
||||
->paginate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class EvCompanyController extends Controller
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return EvCompanyResource::collection(
|
||||
EvCompany::query()->where('is_active', true)->get()
|
||||
EvCompany::query()->where('is_active', true)->paginate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,36 @@ test('lists active destinations', function () {
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('paginates companies and destinations', function () {
|
||||
EvCompany::factory()->count(20)->create(['is_active' => true]);
|
||||
Destination::factory()->count(20)->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
});
|
||||
|
||||
test('searches destinations by comma-separated terms', function () {
|
||||
$yangon = Destination::factory()->create(['is_active' => true, 'name' => 'Yangon']);
|
||||
$mandalay = Destination::factory()->create(['is_active' => true, 'name' => 'Mandalay']);
|
||||
Destination::factory()->create(['is_active' => true, 'name' => 'Bagan']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations?search=yangon,mandalay')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonFragment(['id' => $yangon->id])
|
||||
->assertJsonFragment(['id' => $mandalay->id]);
|
||||
});
|
||||
|
||||
test('companies endpoint rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/companies')->assertUnauthorized();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Accepts either of two bearer schemes on the same routes:
|
||||
*
|
||||
* - A Sanctum personal access token, for real database users (mini app,
|
||||
* mobile, web, admin) — resolved exactly as `auth:sanctum` would.
|
||||
* - A self-signed JWT minted by the FastAPI AI agent, carrying the real
|
||||
* end-customer's identity in its `sub` claim. No Laravel `User` is
|
||||
* created or attached for this path — the verified claim is stashed as
|
||||
* the `fastapi_openid` request attribute for controllers to scope by
|
||||
* (domain.md §8; the agent has no database identity of its own).
|
||||
*
|
||||
* Payment/refund routes deliberately keep plain `auth:sanctum` instead of
|
||||
* this middleware, so a JWT-authenticated request can never reach them.
|
||||
*/
|
||||
class AuthenticateSanctumOrFastApiJwt implements AuthenticatesRequests
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::guard('sanctum')->check()) {
|
||||
Auth::shouldUse('sanctum');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($token = $request->bearerToken()) {
|
||||
try {
|
||||
$payload = JWT::decode($token, new Key(
|
||||
config('services.fastapi_agent.jwt_secret'),
|
||||
config('services.fastapi_agent.jwt_algorithm'),
|
||||
));
|
||||
|
||||
$request->attributes->set('fastapi_openid', $payload->sub);
|
||||
|
||||
return $next($request);
|
||||
} catch (Throwable $e) {
|
||||
// Expired/malformed/wrong-signature tokens are routine auth
|
||||
// failures, not application errors — log at debug level
|
||||
// only, never report() to the error tracker.
|
||||
Log::debug('FastAPI agent JWT rejected.', ['reason' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
abort(401, 'Unauthenticated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.fastapi_agent.jwt_secret' => 'test-fastapi-agent-secret-0123456789ABCDEF']);
|
||||
config(['services.fastapi_agent.jwt_algorithm' => 'HS256']);
|
||||
|
||||
Route::middleware(['api.auth'])
|
||||
->get('/__test/sanctum-or-fastapi-jwt', fn (Request $request) => response()->json([
|
||||
'openid' => $request->attributes->get('fastapi_openid'),
|
||||
'user_id' => $request->user()?->id,
|
||||
]));
|
||||
});
|
||||
|
||||
function fastApiJwt(array $overrides = []): string
|
||||
{
|
||||
$payload = array_merge([
|
||||
'sub' => 'mini-app-openid-123',
|
||||
'iat' => time(),
|
||||
'exp' => time() + 3600,
|
||||
], $overrides);
|
||||
|
||||
return JWT::encode($payload, 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256');
|
||||
}
|
||||
|
||||
test('a valid Sanctum token authenticates as a real user, no openid attribute set', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('test-token')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/__test/sanctum-or-fastapi-jwt')
|
||||
->assertSuccessful()
|
||||
->assertJson(['openid' => null, 'user_id' => $user->id]);
|
||||
});
|
||||
|
||||
test('a valid FastAPI JWT authenticates with the verified openid claim and no user', function () {
|
||||
$token = fastApiJwt(['sub' => 'agent-openid-456']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/__test/sanctum-or-fastapi-jwt')
|
||||
->assertSuccessful()
|
||||
->assertJson(['openid' => 'agent-openid-456', 'user_id' => null]);
|
||||
});
|
||||
|
||||
test('an expired FastAPI JWT is rejected', function () {
|
||||
$token = fastApiJwt(['exp' => time() - 60]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/__test/sanctum-or-fastapi-jwt')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('a FastAPI JWT signed with the wrong secret is rejected', function () {
|
||||
$token = JWT::encode(['sub' => 'agent-openid-456', 'exp' => time() + 3600], 'wrong-secret-0123456789ABCDEFGHIJKLMNOP', 'HS256');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/__test/sanctum-or-fastapi-jwt')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('a malformed bearer token is rejected', function () {
|
||||
$this->withHeader('Authorization', 'Bearer not-a-real-token')
|
||||
->getJson('/__test/sanctum-or-fastapi-jwt')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('a request with no Authorization header is rejected', function () {
|
||||
$this->getJson('/__test/sanctum-or-fastapi-jwt')->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<x-mail::message>
|
||||
# New EV Booking ( {{ $booking->ref_no }} )
|
||||
A new EV booking was created at {{ $booking->created_at }}.
|
||||
|
||||
**Booking Summary**
|
||||
- **Route:** {{ $booking->route->name ?? 'N/A' }}
|
||||
- **Seat Options:** {{ $booking->vehicleOptions->map(fn($b) => "{$b->passenger_count} x {$b->vehicle_option->value}")->implode(', ') }}
|
||||
- **Pickup Location:** {{ $booking->pickup_address ?? 'N/A' }}
|
||||
- **Dropoff Location:** {{ $booking->dropoff_address ?? 'N/A' }}
|
||||
- **Travel Date:** {{ $booking->travel_date ?? 'N/A' }}
|
||||
- **Total Price:** {{ $booking->price ?? 'N/A' }}
|
||||
|
||||
{{-- @if(!empty($booking->notes))
|
||||
**Notes:**<br>
|
||||
{{ $booking->notes }}
|
||||
@endif --}}
|
||||
|
||||
Payment Method: {{ ($payment->gateway ?? 'N/A') }}
|
||||
<br>
|
||||
Contact Info: {{ ($booking->passenger_name ?? '') }} {{ $booking->passenger_phone ?? '' }}
|
||||
<br>
|
||||
Channel: {{ $booking->created_by_channel ?? '-' }}
|
||||
|
||||
<x-mail::button :url="$url">
|
||||
View Booking
|
||||
</x-mail::button>
|
||||
|
||||
Auto generated from<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
@@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController;
|
||||
use Modules\Payment\Http\Controllers\PaymentWebhookController;
|
||||
use Modules\Payment\Http\Controllers\RefundController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
|
||||
Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate');
|
||||
Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund');
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
@@ -19,6 +20,13 @@ use Modules\Payment\Services\PaymentService;
|
||||
*
|
||||
* Booking status only ever flips to `confirmed` once the gateway confirms
|
||||
* success via the webhook/verify path (T5.9/T5.10) — never here.
|
||||
*
|
||||
* Idempotent per booking: KBZ's precreate rejects a second call tied to an
|
||||
* order that's still in flight, so a repeat call (double-tap on "Pay", the
|
||||
* customer re-opening the payment screen) must not blindly precreate again.
|
||||
* If the latest attempt is still `pending`, it's re-verified against the
|
||||
* gateway (via ConfirmPaymentAction, the same logic the webhook path uses)
|
||||
* and reused instead of starting a new one.
|
||||
*/
|
||||
class InitiatePaymentAction
|
||||
{
|
||||
@@ -26,6 +34,7 @@ class InitiatePaymentAction
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
private ConfirmPaymentAction $confirmPayment,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
|
||||
@@ -34,27 +43,46 @@ class InitiatePaymentAction
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
return DB::transaction(function () use ($booking, $method) {
|
||||
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
$latest = $booking->payments()->latest('id')->first();
|
||||
|
||||
return DB::transaction(fn () => Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]));
|
||||
if ($latest !== null) {
|
||||
// No-op for an already-terminal payment (ConfirmPaymentAction
|
||||
// only re-verifies `pending` ones), so this is cheap even for
|
||||
// a Failed/Completed latest attempt — and it guards against a
|
||||
// narrow race where a webhook already completed the payment
|
||||
// but the queued booking-status listener hasn't run yet.
|
||||
$reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id);
|
||||
|
||||
if ($reverified !== null && $reverified->status !== PaymentStatus::Failed) {
|
||||
return $reverified;
|
||||
}
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
|
||||
return Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Failure rate over the trailing 30 days, among payments that reached a
|
||||
* terminal state (completed or failed) — pending attempts are excluded
|
||||
* since they haven't resolved either way yet.
|
||||
*/
|
||||
class PaymentFailureRateWidget extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$since = today()->subDays(30);
|
||||
|
||||
$completed = Payment::query()
|
||||
->where('status', PaymentStatus::Completed)
|
||||
->where('initiated_at', '>=', $since)
|
||||
->count();
|
||||
|
||||
$failed = Payment::query()
|
||||
->where('status', PaymentStatus::Failed)
|
||||
->where('initiated_at', '>=', $since)
|
||||
->count();
|
||||
|
||||
$resolved = $completed + $failed;
|
||||
$rate = $resolved > 0 ? round(($failed / $resolved) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
Stat::make('Payment Failure Rate', $rate.'%')
|
||||
->description("{$failed} failed of {$resolved} resolved (last 30 days)")
|
||||
->color($rate >= 20 ? 'danger' : ($rate > 0 ? 'warning' : 'success')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* 30-day revenue trend from completed payments. Cached per day (T7.1) —
|
||||
* refreshes at most once every 5 minutes since this aggregates a whole
|
||||
* month of rows on every dashboard load otherwise.
|
||||
*/
|
||||
class RevenueChartWidget extends ChartWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected ?string $heading = 'Revenue (Last 30 Days)';
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$days = Cache::tags('payments')->remember(
|
||||
'dashboard:revenue-chart:'.today()->toDateString(),
|
||||
now()->addMinutes(5),
|
||||
fn () => $this->revenueByDay(),
|
||||
);
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Revenue',
|
||||
'data' => $days->pluck('total')->all(),
|
||||
'fill' => true,
|
||||
],
|
||||
],
|
||||
'labels' => $days->pluck('label')->all(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{label: string, total: float}>
|
||||
*/
|
||||
private function revenueByDay(): Collection
|
||||
{
|
||||
$start = today()->subDays(29);
|
||||
|
||||
$totals = Payment::query()
|
||||
->where('status', PaymentStatus::Completed)
|
||||
->whereDate('completed_at', '>=', $start)
|
||||
->selectRaw('DATE(completed_at) as day, SUM(amount) as total')
|
||||
->groupBy('day')
|
||||
->pluck('total', 'day');
|
||||
|
||||
return collect(range(0, 29))
|
||||
->map(function (int $offset) use ($start, $totals) {
|
||||
$date = $start->copy()->addDays($offset);
|
||||
|
||||
return [
|
||||
'label' => $date->format('M j'),
|
||||
'total' => (float) ($totals[$date->toDateString()] ?? 0),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly string $createOrderUrl;
|
||||
|
||||
private readonly string $queryOrderUrl;
|
||||
|
||||
private readonly string $refundOrderUrl;
|
||||
|
||||
private readonly ?string $notifyUrl;
|
||||
|
||||
private readonly ?string $certPath;
|
||||
@@ -53,6 +59,11 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$this->merchantCode = (string) ($config['merchant_code'] ?? '');
|
||||
$this->merchantKey = (string) ($config['merchant_key'] ?? '');
|
||||
$this->baseUrl = (string) ($config['base_url'] ?? '');
|
||||
// Falls back to base_url for gateways/environments that haven't
|
||||
// configured per-operation endpoints yet.
|
||||
$this->createOrderUrl = (string) ($config['create_order_url'] ?? $this->baseUrl);
|
||||
$this->queryOrderUrl = (string) ($config['query_order_url'] ?? $this->baseUrl);
|
||||
$this->refundOrderUrl = (string) ($config['refund_order_url'] ?? $this->baseUrl);
|
||||
$this->notifyUrl = $config['notify_url'] ?? null;
|
||||
$this->certPath = $config['cert_path'] ?? null;
|
||||
$this->certKeyPath = $config['cert_key_path'] ?? null;
|
||||
@@ -63,10 +74,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
$params = $this->buildPrecreateParams($data);
|
||||
|
||||
logger($params);
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
$response = Http::post($this->createOrderUrl, ['Request' => $params]);
|
||||
|
||||
logger($response);
|
||||
} catch (ConnectionException $exception) {
|
||||
\Log::error('KBZ Mini App precreate connection error: '.$exception->getMessage(), [
|
||||
'merchant_order_id' => $data->merchantOrderId,
|
||||
'amount' => $data->amount,
|
||||
'currency' => $data->currency,
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: null,
|
||||
@@ -79,6 +98,14 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$body = $response->json('Response', []);
|
||||
|
||||
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
|
||||
\Log::error('KBZ Mini App precreate failed: '.($body['msg'] ?? 'Unknown error'), [
|
||||
'merchant_order_id' => $data->merchantOrderId,
|
||||
'amount' => $data->amount,
|
||||
'currency' => $data->currency,
|
||||
'http_status' => $response->status(),
|
||||
'raw_body' => $response->body(),
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $body['prepay_id'] ?? null,
|
||||
@@ -87,13 +114,19 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
);
|
||||
}
|
||||
|
||||
$orderInfo = $this->createOrderInfo($body['prepay_id'] ?? '');
|
||||
|
||||
return new PaymentResultData(
|
||||
// KBZ's queryorder/refund calls both key off our own merch_order_id,
|
||||
// not their prepay_id — so that's what gets stored/passed forward as
|
||||
// the gateway transaction id (prepay_id still lives in the payload).
|
||||
status: PaymentStatus::Pending,
|
||||
gatewayTransactionId: $data->merchantOrderId,
|
||||
gatewayPayload: $body,
|
||||
gatewayPayload: [
|
||||
'prepayId' => $body['prepay_id'] ?? null,
|
||||
'orderInfo' => KbzSignature::joinKeyVal($orderInfo),
|
||||
'signature' => KbzSignature::sign($orderInfo, $this->merchantKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,8 +135,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = $this->buildQueryOrderParams($gatewayTransactionId);
|
||||
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
$response = Http::asJson()->post($this->queryOrderUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
\Log::error('KBZ Mini App verify connection error: '.$exception->getMessage(), [
|
||||
'gateway_transaction_id' => $gatewayTransactionId,
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
@@ -130,7 +167,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions($this->mtlsOptions())
|
||||
->post($this->baseUrl, ['Request' => $params]);
|
||||
->post($this->refundOrderUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
return new RefundResultData(
|
||||
status: RefundStatus::Failed,
|
||||
@@ -208,7 +245,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -235,7 +272,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.queryorder',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -272,7 +309,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.refund',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -326,4 +363,15 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function createOrderInfo($prepayId): array
|
||||
{
|
||||
return [
|
||||
'appid' => $this->appId,
|
||||
'merch_code' => $this->merchantCode,
|
||||
'nonce_str' => uniqid(),
|
||||
'prepay_id' => $prepayId,
|
||||
'timestamp' => (string)time()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace Modules\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\InitiatePaymentAction;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Http\Resources\PaymentResource;
|
||||
|
||||
class PaymentController extends Controller
|
||||
@@ -15,12 +17,25 @@ class PaymentController extends Controller
|
||||
private InitiatePaymentAction $initiatePaymentAction,
|
||||
) {}
|
||||
|
||||
public function initiate(Booking $booking): JsonResponse
|
||||
public function initiate(Request $request, Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('pay', $booking);
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('create', Booking::class);
|
||||
}
|
||||
|
||||
$payment = $this->initiatePaymentAction->handle($booking);
|
||||
|
||||
if ($payment->status === PaymentStatus::Failed) {
|
||||
return response()->json([
|
||||
'message' => 'Payment initiation failed',
|
||||
'errors' => [
|
||||
'payment' => ['Payment initiation failed'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return (new PaymentResource($payment))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
|
||||
@@ -18,10 +18,14 @@ class RefundController extends Controller
|
||||
|
||||
public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('refund', $booking);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('refund', $booking);
|
||||
}
|
||||
|
||||
$refund = $this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $validated['amount'],
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
class NewBookingAlert extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Booking $booking;
|
||||
|
||||
public string $url;
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public Payment $payment)
|
||||
{
|
||||
$this->booking = $payment->booking;
|
||||
$this->url = BookingResource::getUrl('view', ['record' => $this->booking]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'FamousLY4 New Booking Alert',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'payment::mails.new-booking-alert',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Observers;
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Mail\NewBookingAlert;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
class PaymentObserver
|
||||
{
|
||||
public function created(Payment $payment): void
|
||||
{
|
||||
if ($payment->status === PaymentStatus::Failed) return;
|
||||
|
||||
try {
|
||||
$admin_emails = config('booking.admin_emails');
|
||||
|
||||
Mail::bcc($admin_emails)->queue(new NewBookingAlert($payment));
|
||||
} catch (\Exception $e) {
|
||||
// Log the exception or handle it as needed
|
||||
\Log::error('Failed to send NewBookingAlert email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function updated(Payment $payment): void
|
||||
{
|
||||
// Handle the event when a payment is updated for a booking
|
||||
}
|
||||
|
||||
public function deleted(Payment $payment): void
|
||||
{
|
||||
// Handle the event when a payment is deleted for a booking
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Observers\PaymentObserver;
|
||||
|
||||
class PaymentServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -28,5 +30,7 @@ class PaymentServiceProvider extends ServiceProvider
|
||||
{
|
||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
||||
|
||||
Payment::observe(PaymentObserver::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?PaymentRequestData $lastRequest = null;
|
||||
|
||||
public static int $initiateCalls = 0;
|
||||
|
||||
public static int $verifyCalls = 0;
|
||||
|
||||
public static PaymentStatus $verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
self::$lastRequest = $data;
|
||||
self::$initiateCalls++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Pending,
|
||||
@@ -34,7 +41,13 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
self::$verifyCalls++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: self::$verifyStatus,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: ['trade_status' => self::$verifyStatus->value],
|
||||
);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
@@ -53,6 +66,11 @@ beforeEach(function () {
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class);
|
||||
|
||||
FakeInitiatePaymentGateway::$lastRequest = null;
|
||||
FakeInitiatePaymentGateway::$initiateCalls = 0;
|
||||
FakeInitiatePaymentGateway::$verifyCalls = 0;
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
@@ -79,6 +97,44 @@ test('the owner can initiate payment for their own pending_payment booking', fun
|
||||
->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1");
|
||||
});
|
||||
|
||||
test('a repeat call while the previous attempt is still pending reuses it instead of precreating again', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
$firstPaymentId = Payment::where('booking_id', $booking->id)->sole()->id;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.id', $firstPaymentId);
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$verifyCalls)->toBe(1);
|
||||
});
|
||||
|
||||
test('a repeat call reused attempt found completed on re-verify is returned without precreating again', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Completed;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', PaymentStatus::Completed->value);
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1);
|
||||
});
|
||||
|
||||
test('a retried payment attempt gets a unique merchant order id', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Payment\Filament\Widgets\PaymentFailureRateWidget;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
test('it computes the failure rate among resolved payments in the last 30 days', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||
Payment::factory()->failed()->create(['initiated_at' => today()]);
|
||||
Payment::factory()->create(['initiated_at' => today()]); // pending, excluded from resolved total
|
||||
Payment::factory()->failed()->create(['initiated_at' => today()->subDays(40)]); // outside window
|
||||
|
||||
Livewire::test(PaymentFailureRateWidget::class)
|
||||
->assertOk()
|
||||
->assertSee('25%')
|
||||
->assertSee('1 failed of 4 resolved (last 30 days)');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Filament\Widgets\RevenueChartWidget;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
test('it sums completed payment amounts per day over the last 30 days', function () {
|
||||
Payment::factory()->completed()->create(['amount' => 10000, 'completed_at' => today()]);
|
||||
Payment::factory()->completed()->create(['amount' => 5000, 'completed_at' => today()]);
|
||||
Payment::factory()->create(['amount' => 99999, 'completed_at' => null]); // pending, excluded
|
||||
Payment::factory()->completed()->create(['amount' => 77777, 'completed_at' => today()->subDays(40)]); // outside window
|
||||
|
||||
$widget = new RevenueChartWidget;
|
||||
$getData = (new ReflectionMethod($widget, 'getData'));
|
||||
$getData->setAccessible(true);
|
||||
$data = $getData->invoke($widget);
|
||||
|
||||
expect($data['labels'])->toHaveCount(30)
|
||||
->and(array_sum($data['datasets'][0]['data']))->toBe(15000.0);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Routing\Http\Controllers\EvRouteController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index');
|
||||
Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show');
|
||||
Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing');
|
||||
|
||||
@@ -25,9 +25,10 @@ class EvRouteController extends Controller
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$filters = $request->only(['company', 'from', 'to', 'date']);
|
||||
$page = $request->integer('page', 1);
|
||||
|
||||
$routes = Cache::tags(self::CACHE_TAG)->remember(
|
||||
'routes:index:'.md5(json_encode($filters)),
|
||||
'routes:index:'.md5(json_encode($filters + ['page' => $page])),
|
||||
now()->addMinutes(self::CACHE_TTL_MINUTES),
|
||||
fn () => EvRoute::query()
|
||||
->where('is_active', true)
|
||||
@@ -37,7 +38,7 @@ class EvRouteController extends Controller
|
||||
// `date` is accepted for forward-compatibility with future per-date capacity
|
||||
// checks (domain.md §7), but v1 has no route-level calendar to filter against.
|
||||
->with(self::EAGER_LOADS)
|
||||
->get(),
|
||||
->paginate(),
|
||||
);
|
||||
|
||||
return EvRouteResource::collection($routes);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Routing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -91,4 +92,11 @@ class EvRoute extends Model
|
||||
{
|
||||
return $this->hasMany(RoutePricing::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
public function name(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,16 @@ test('filters routes by company, from, and to', function () {
|
||||
->assertJsonPath('data.0.id', $matching->id);
|
||||
});
|
||||
|
||||
test('paginates routes', function () {
|
||||
EvRoute::factory()->count(20)->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
});
|
||||
|
||||
test('shows a single active route', function () {
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ class AdminPanelProvider extends PanelProvider
|
||||
->path('admin')
|
||||
->viteTheme('resources/css/filament/admin/theme.css')
|
||||
->login()
|
||||
->brandLogo(asset('images/logo.png'))
|
||||
->brandLogoHeight('2.5rem')
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"nightwatch": false,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
"laravel-best-practices",
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
|
||||
use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
@@ -25,6 +26,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'fastapi.agent' => EnsureFastApiAgent::class,
|
||||
'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
+2
-1
@@ -8,10 +8,12 @@
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"filament/filament": "^4.0",
|
||||
"firebase/php-jwt": "^7.1",
|
||||
"gboquizosanchez/filament-log-viewer": "^2.3",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"internachi/modular": "^3.0",
|
||||
"modules/booking": "*",
|
||||
"modules/catalog": "*",
|
||||
"modules/identity": "*",
|
||||
@@ -23,7 +25,6 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"internachi/modular": "^3.0",
|
||||
"laravel/boost": "^2.2",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
|
||||
Generated
+1669
-1587
File diff suppressed because it is too large
Load Diff
@@ -29,4 +29,6 @@ return [
|
||||
|
||||
'front_seat_max_per_booking' => env('BOOKING_FRONT_SEAT_MAX_PER_BOOKING', 1),
|
||||
|
||||
// comma-separated list of admin emails to notify on booking events
|
||||
'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')),
|
||||
];
|
||||
|
||||
@@ -35,11 +35,19 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'fastapi_agent' => [
|
||||
'jwt_secret' => env('FASTAPI_AGENT_JWT_SECRET'),
|
||||
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
||||
],
|
||||
|
||||
'kbz' => [
|
||||
'app_id' => env('KBZ_APP_ID'),
|
||||
'merchant_code' => env('KBZ_MERCHANT_CODE'),
|
||||
'merchant_key' => env('KBZ_MERCHANT_KEY'),
|
||||
'base_url' => env('KBZ_BASE_URL'),
|
||||
'create_order_url' => env('KBZ_CREATE_ORDER_URL'),
|
||||
'query_order_url' => env('KBZ_QUERY_ORDER_URL'),
|
||||
'refund_order_url' => env('KBZ_REFUND_ORDER_URL'),
|
||||
'notify_url' => env('KBZ_NOTIFY_URL'),
|
||||
'cert_path' => env('KBZ_CERT_PATH'),
|
||||
'cert_key_path' => env('KBZ_CERT_KEY_PATH'),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user