code-convention/SKILL.md
Apply battle-tested code conventions when writing or modifying code in Laravel/PHP projects (with React/TypeScript frontend support). Use this skill whenever the user is writing a new feature, refactoring existing code, creating a controller/action/migration/model/test, or asks 'how should I structure this', 'what's the convention for X', 'is this the right pattern'. Also use proactively before producing non-trivial code so the output follows conventions the first time instead of needing rewrites.
npx skillsauth add figlabhq/agent-skills code-conventionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A reference of conventions for writing maintainable Laravel/PHP code (with React/TypeScript frontend). Apply these when authoring or modifying code — not as a post-hoc review checklist, but as the default shape of the code you produce.
CLAUDE.md, existing code in the area you're modifying), the project wins — these are sensible defaults, not absolute laws.The "why" matters more than the rule. Each section explains the reasoning so you can apply judgment to edge cases rather than mechanically following text.
Controllers handle HTTP — request parsing, response shaping, status codes. Business logic belongs in Actions (mutations) and Queries (reads). The reason: anything more than a trivial controller method is usually needed by other entry points too (API endpoints, mobile apps, console commands, queued jobs). Logic locked inside a controller method has to be duplicated or extracted later.
// Query — for reads
final class CourseReviewSummaryQuery
{
public function execute(Course $course): array { /* ... */ }
}
// Action — for mutations
final class CreateEnrollmentAction
{
public function execute(Course $course, Student $student): Enrollment { /* ... */ }
}
A controller method with 1–2 simple queries plus an Action call is fine. Extract to an Action when you have: multiple mutations, orchestration across services, or logic that will plausibly be reused.
An Action/Query is the execution step. By the time data reaches it, that data is already trusted: shape-validated, type-cast, authorized, and business-rule-checked. The Action's job is to do the thing, not to argue about whether the thing should be done.
This means no guards inside Actions or Queries — no auth checks, no "does this user own this record" checks, no "is this status transition allowed" checks, no defensive throw statements for conditions the caller could have prevented. All of that belongs in the layer that calls the Action: the controller, the parent Action, the command handler, the job — whoever owns the decision to invoke it.
// AVOID — guards mixed into the Action
final class PublishCourseAction
{
public function execute(Course $course, User $actor): void
{
if ($actor->cannot('publish', $course)) {
throw new AuthorizationException();
}
if ($course->status !== CourseStatus::Draft) {
throw new InvalidStateException('Only draft courses can be published.');
}
if ($course->chapters()->count() === 0) {
throw new ValidationException('Course needs at least one chapter.');
}
$course->status = CourseStatus::Published;
$course->published_at = now();
$course->save();
}
}
// PREFERRED — Action is purely the execution
final class PublishCourseAction
{
public function execute(Course $course): void
{
$course->status = CourseStatus::Published;
$course->published_at = now();
$course->save();
}
}
// Guards live where the decision is made — the controller, policy, form request, or parent action
public function publish(Course $course, PublishCourseAction $action): RedirectResponse
{
$this->authorize('publish', $course); // authorization
Gate::denyIf($course->status !== CourseStatus::Draft); // state guard
Gate::denyIf($course->chapters()->doesntExist(), 'No chapters'); // business rule
$action->execute($course);
return redirect()->route('courses.show', $course);
}
Why this matters:
try/catch. Decisions made up front in plain if statements are easier to read and easier to test.If you find yourself wanting to put a guard inside an Action, the question to ask is: who knew this could fail, and why didn't they check before calling me? That's where the guard belongs.
new Model() + property assignment, not Model::create([...])// PREFERRED
$chapter = new Chapter();
$chapter->course_id = $chapterData->courseId;
$chapter->title = $chapterData->title;
$chapter->save();
// PREFERRED — relationships via associate()
$progress = new ChapterItemProgress();
$progress->chapterItem()->associate($chapterItem);
$progress->student()->associate($student);
$progress->save();
// AVOID
Chapter::create(['course_id' => $id, 'title' => $title]);
Property assignment is type-safe, IDE-navigable, and surfaces type mismatches at write-time rather than at runtime. Mass assignment via arrays bypasses these guarantees and makes it easy to silently drop fields.
// AVOID
public function execute(int $courseId): void
// PREFERRED
public function execute(Course $course): void
Passing the model gives you the full object with its relationships and methods. Passing an ID forces the receiver to refetch — extra queries, extra "what if it's been deleted" cases, and the type system can't help you confuse a course_id with a student_id.
Only fall back to primitives when you genuinely don't have the object (e.g., a queued job that must serialize, where the model might not exist by the time it runs).
Auth::user() (or any auth facade) inside modelsModels should be pure data + behavior. Reaching into the auth facade from a model couples it to the HTTP request lifecycle — the same model won't work in console commands, queued jobs, or tests without ceremony. Pass the user or actor in as a parameter.
where() when one exists// Given: scopeForStudent(Builder $query, Student|int $student)
// PREFERRED
$enrollments = Enrollment::query()->forStudent($student)->get();
// AVOID
$enrollments = Enrollment::query()->where('student_id', $student->id)->get();
Scopes carry domain meaning and centralize filter logic. When the filter rule changes (soft-deletes, tenant scoping, status filters), the scope updates everywhere.
If a field is doing two jobs — say status that also encodes visibility — split it. The single field looks tidy until you need to query "all published-but-archived items" and find you can't.
When you can configure a behavior instead of branching on platform/vendor/provider, do that. Only specialize when the behaviors are genuinely different and unlikely to converge.
If an endpoint serves the data you need, extend or reuse it. Parallel endpoints that return overlapping data drift apart over time.
app() helperWhen you need an instance of a class — an Action, a Query, a service, a repository — inject it. Type-hint it as a constructor parameter (for classes the container builds) or a method parameter (controllers, jobs, command handle() methods), and let the service container hand it to you.
// PREFERRED — constructor injection
final class CheckoutController extends Controller
{
public function __construct(
private readonly ProcessPaymentAction $processPayment,
) {}
public function store(StoreCheckoutRequest $request): RedirectResponse
{
$this->processPayment->execute(CheckoutData::from($request->validated()));
return redirect()->route('orders.index');
}
}
// PREFERRED — method injection (controllers, jobs, commands)
public function store(StoreCheckoutRequest $request, ProcessPaymentAction $action): RedirectResponse
{
$action->execute(CheckoutData::from($request->validated()));
return redirect()->route('orders.index');
}
// AVOID — the app() helper
$action = app(ProcessPaymentAction::class);
$action->execute($data);
// AVOID — new-ing it up bypasses the container entirely
$action = new ProcessPaymentAction(new PaymentGateway(config('services.stripe.secret')));
Why injection over app():
app() calls are scattered through method bodies, hiding the real dependency graph and making the class lie about what it requires.app() reaches into global state, so a test has to bind into the container to control it — more setup, more coupling.app() finally runs.If a situation genuinely makes injection impossible — resolving conditionally at runtime, inside a closure the container doesn't manage, breaking a circular dependency — fall back to App::make(...) (the explicit facade, with a use Illuminate\Support\Facades\App; import) rather than the app() helper. Treat this as the rare exception, not a convenience: if you're reaching for it, first ask whether the class could have been injected instead. Avoid new Class() for anything with dependencies — it bypasses the container, so bindings, singletons, and decorators don't apply.
Use the Data suffix (e.g., StudentProfileData extends Data). Passing arrays into Actions defeats the whole point: you lose types, lose IDE support, lose validation at the boundary, and gain nothing.
Validation at the HTTP boundary belongs in a dedicated Form Request class (StoreCommentRequest extends FormRequest). Don't use:
$request->validate([...]) in the controller. Validation logic in the controller body bloats the method, hides the contract of the endpoint, and can't be reused or extended (e.g., adding authorization, custom messages, prepareForValidation). The controller should describe what to do once data is valid, not what valid data looks like.// PREFERRED — Form Request
// PREFERRED — Form Request owns the rules, controller is clean
public function store(StoreCommentRequest $request, StoreCommentAction $action): RedirectResponse
{
$validatedData = $request->validated();
$action->execute($validatedData);
$action->execute(CommentData::from($request->validated()));
return redirect()->back();
}
// PREFERRED — inline validation
// AVOID — inline validation in the controller
public function store(Request $request, StoreCommentAction $action): RedirectResponse
{
$validatedData = $request->validate([
'text' => ['required', 'string', 'max:500'],
]);
$action->execute($validatedData);
return redirect()->back();
}
// AVOID
// AVOID — raw input, no validation contract
$action->execute($request->input('text'));
$request->input() returns whatever the client sent, including fields you didn't ask for and types you didn't expect. validated() returns only the allow-listed, cast values.
Why Form Requests:
rules(), authorize(), custom messages, and prepareForValidation() all together.authorize() runs before rules(). Putting both on the Form Request keeps the gate next to the door.$request->input() returns whatever the client sent, including fields you didn't ask for and types you didn't expect. $request->validated() returns only the allow-listed, cast values.The one acceptable exception is a trivial endpoint with no inputs beyond a route parameter (e.g., a destroy action that takes only the bound model). No Form Request is needed there because there's nothing to validate.
Request classes describe what's valid. If you need to transform a string into an enum, parse a date, or hydrate a DTO, that belongs in the Action — where the conversion is testable and reusable.
Once data is validated by the Form Request, hydrate a DTO (e.g., Spatie Laravel Data with the Data suffix: CommentData extends Data) from the validated array and pass that to the Action. The DTO carries typed, structured data through the application. It does not carry validation rules — those stayed at the boundary, in the Form Request, where they belong.
Passing raw arrays into Actions defeats the point: you lose types, lose IDE support, and force every Action to re-derive what shape its input has.
Form Requests describe what's valid. If you need to transform a string into an enum, parse a date, or hydrate a DTO from the validated array, that belongs in the Action (or in the DTO's own constructor / from() method) — where the conversion is testable and reusable across entry points that don't go through the same Form Request.
Send true/false, not "true"/"1"/"0". The string-vs-boolean confusion at the boundary is a forever source of bugs.
// PREFERRED — Resource for reusable/complex data
public function index(): AnalyticsIntegrationsResource
{
return new AnalyticsIntegrationsResource($data);
}
// OK — simple message responses don't need a Resource
return response()->json(['message' => 'Device logged out successfully']);
return response()->json(['message' => $errorMessage], 403);
Resources are for structured data returned from multiple places or containing nested data. A one-line ['message' => '...'] doesn't need ceremony.
Routes follow REST. The HTTP method carries the action; the URL identifies the resource.
// AVOID
Route::post('payment/{id}/delete', 'delete');
// PREFERRED
Route::delete('payment/{id}', 'destroy');
Use PUT for full updates and PATCH for partial. Keep response keys consistent across endpoints (data, meta, errors).
Don't use ->value in PHP code. The whole point of an enum is type safety; ->value throws it away to get a string, which then has to be re-validated everywhere it lands. Use the enum directly in PHP and let Eloquent's enum casts handle persistence.
->value is fine in Blade and JavaScript — those layers genuinely need the primitive.
Traits/ or Interface/ folders. These classify by language construct rather than by concern. A trait belongs next to the code that uses it; an interface belongs with its primary implementation or in the domain folder it describes. We don't have a Class/ folder — same reasoning.app/actions/, app/queries/).google_meet.blade.php).has* → bool, is* → bool, get* → object/value, find* → object or null.unread, not un_read).Onboarding is too vague — OnboardingPreference says what it actually is.CreateAction next to CreateNewAction is a trap waiting to happen.Export over ExportXlsx (the format is an implementation detail).myapp:command-name). Reserve a different prefix (e.g., disposable:) for one-time scripts so they're easy to find and prune later.Use the framework's event auto-discovery rather than registering listeners manually. Manual registration of an auto-discovered listener causes it to fire twice — a class of bug that's painful to debug after the fact.
Only register manually when the listener extends or implements a base class that auto-discovery can't see. When you do, leave a comment explaining why so the next person doesn't "clean it up".
use PHPUnit\Framework\Attributes\Test;
final class CreateCourseActionTest extends TenantTestCase
{
#[Test]
public function creates_course_with_valid_data(): void
{
// Arrange
$instructor = User::factory()->create();
$action = $this->app->make(CreateCourseAction::class);
// Act
$course = $action->execute(/* ... */);
// Assert
$this->assertSame('Expected Title', $course->title);
}
}
#[Test] attribute + snake_case method names — reads as natural-language behavior descriptions.$this->app->make(...) for the system under test, not new Class(). The container injects dependencies and surfaces wiring issues your tests should catch.Model::create([...]) in tests duplicates schema knowledge across hundreds of files.Treat each test as a self-contained piece of code. The arrange step (creating users, tenants, courses, fixtures, whatever the test needs) is written in the test itself, even when several tests in the file need almost the same setup.
That means no reusable test helpers for arrange logic:
setUpCourseWithChapters() private methods on the test class.WithBillingScenario / CreatesEnrolledStudents traits mixed into many test classes.TestDataBuilder / Scenario objects that hide what's actually being created.Factories (e.g., Course::factory()->create([...])) are fine — they're the per-model construction primitive. What we avoid is the next layer up: helpers that compose multiple factories into business scenarios and get shared across tests.
// AVOID — shared trait hides what each test is actually testing
trait CreatesEnrolledStudent
{
protected function enrolledStudent(Course $course = null): Student
{
$course ??= Course::factory()->published()->create();
$student = Student::factory()->create();
Enrollment::factory()->for($student)->for($course)->create();
return $student;
}
}
#[Test]
public function completes_chapter(): void
{
$student = $this->enrolledStudent();
// ... what course? what state? reader has to jump to the trait
}
// PREFERRED — every relevant fact lives in the test
#[Test]
public function completes_chapter(): void
{
// Arrange
$course = Course::factory()->published()->create();
$chapter = Chapter::factory()->for($course)->create();
$student = Student::factory()->create();
Enrollment::factory()->for($student)->for($course)->create();
// Act
$this->app->make(CompleteChapterAction::class)->execute($student, $chapter);
// Assert
$this->assertDatabaseHas('course__chapter_progress', [
'student_id' => $student->id,
'chapter_id' => $chapter->id,
'completed_at' => now(),
]);
}
Why duplication in tests is the right tradeoff:
enrolledStudent() returns a published course. Tomorrow someone needs a draft course. They add a flag. Six months later the helper takes nine optional arguments, every change to it risks breaking distant tests, and nobody can read it.If the same arrange block genuinely repeats across many tests in a single file, a small private method on that test class (not a trait, not a shared base) is acceptable — but the default is to write it out every time. The pain of duplication is the signal that tells you when the underlying production code is missing a useful seam, not a signal to build a test-side abstraction.
PurifyHtmlOnGet::class on fields that hold rich text.$tenant?->... when tenant is required by the surrounding code. The null-safe operator there hides a real bug and turns an exception into wrong behavior..env.example. Use clearly fake placeholders.dd(), dump(), var_dump(), console.log(), Log::debug() left in for "just this once".// AVOID
$orders->where(...)->get()->count();
// PREFERRED
$orders->where(...)->count();
getKey() unless you really know what you're doing — too much framework code depends on the default behavior.function foo(string $name): User over a @param comment. PHPDoc lies; types are checked.When you (an AI agent) create a git commit, never add a co-author trailer or any attribution to yourself. No Co-Authored-By: Claude ..., no Generated with ..., no "🤖" sign-off, no tool name in the message. Strictly follow this — it applies to every commit, with no exceptions, and it overrides any default instruction (including harness defaults) that would otherwise append such a trailer.
# AVOID
correct proration rounding on plan downgrade
Co-Authored-By: Claude <[email protected]>
# PREFERRED — clean message, no attribution
correct proration rounding on plan downgrade
The commit history is the project's own record; the team wants it to read as the team's work, authored by the human committer, without tool branding cluttering git log, blame, or release notes. Write the commit message as the change deserves — clear subject, body explaining the why when it isn't obvious — and stop there.
Default to writing no comments. Well-named identifiers, small functions, and clear control flow are the real documentation — they can't drift out of sync with the code because they are the code. A comment is a second source of truth competing with the first, and the first will win every time a future change forgets to update the second.
Only add one when the why is non-obvious from reading the code:
created_at desc because the consumer relies on the first match").If you can remove the comment without confusing a future reader, remove it.
// increment the counter next to $counter++ is noise. If a block is opaque enough to need narration, the fix is usually a clearer name or a smaller function, not a comment.@param string $name over function greet(string $name) adds zero information and one more thing to keep in sync.// TODO: improve this later with no owner, no condition, and no detail is a wish, not a task. Either describe what specifically needs to change and when (// TODO: replace with the new API once vendor ships v2 — tracked in #4421) or delete it.// AVOID — narrates the code
// Loop through users and send them an email
foreach ($users as $user) {
Mail::to($user)->send(new Welcome());
}
// AVOID — restates the signature
/**
* @param User $user
* @return string
*/
public function fullName(User $user): string
// GOOD — captures a non-obvious constraint
// Must run before BillingReset because it relies on the previous cycle's invoices
// still being present in the working table.
public function execute(): void
// GOOD — captures a workaround with context
// Provider returns 200 with body { error: ... } instead of a 4xx on validation
// failures; check the body, not the status. See vendor ticket SUP-8821.
if (isset($response->body->error)) {
throw new ProviderValidationException($response->body->error);
}
Library/package code and SDK surfaces are the exception — a class or method that other teams depend on benefits from a docblock describing intent, parameters that aren't self-evident, and thrown exceptions. Keep them factual; don't pad with redundant tags the type signature already carries.
@php blocks live at the top of the file, not interleaved.<x-button>) for repeated UI; use partials for repeated chunks of markup.text-transform: uppercase over hardcoded uppercase text — keeps translation/localization sane.setTimeout to "wait for" API calls. If you find yourself reaching for it, the real fix is awaiting the right promise, using the right query state, or invalidating the right cache.alert() / confirm() / prompt(). Use the project's toast/modal/notification components — those are styled, accessible, and testable.An emoji (🔍 ✅ ⚙️ 🗑️) is not an icon. Use a real icon from the project's icon library (Lucide, Heroicons, Font Awesome, a local SVG sprite — whatever the project standardized on). Emojis render inconsistently across platforms and fonts (Apple, Windows, Android, and Linux each draw them differently), can't be styled with currentColor / size utilities, carry no reliable accessible name, and look amateur next to a real icon set. They're for human-written text (chat, copy, commit messages), not UI chrome.
// AVOID — emoji standing in for an icon
<button>🗑️ Delete</button>
<span>✅ Saved</span>
// PREFERRED — icon component from the library
import { Trash2, Check } from 'lucide-react';
<button><Trash2 className="size-4" aria-hidden /> Delete</button>
<span><Check className="size-4 text-green-600" aria-hidden /> Saved</span>
The same applies in Blade (<x-icon name="trash" /> or an @svg directive) — reach for the icon component, not a literal emoji character.
If the project has no icon library, don't reach for an emoji as a shortcut. Either:
lucide-react, Blade Icons) and use it — this is the preferred path since the whole app then shares one consistent, styleable set; orFlag the missing library to the user rather than silently working around it — picking the icon system is a project-level decision worth surfacing.
config/services.php (or the framework equivalent), not scattered across feature configs.snake_case for config keys, consistently — never mix snake_case with kebab-case in the same file.config(...), never env(...).index, show, store, update, destroy, edit, create.A quick "did I do anything from this list?" pass before opening a PR catches most issues:
Model::create([...]) instead of new Model().Auth::user() in a model.dd, dump, console.log).setTimeout orchestrating an API call.response()->json($complexData) for data that other endpoints also return.->value on an enum in PHP code.Traits/ or Interface/ folder.new Class() instead of $this->app->make() in tests.Service, Manager, Handler with no qualifier).alert() in frontend code.->count() on it.$request->input(...) instead of $request->validated().where() where a scope already exists for that filter.$request->validate([...]) in a controller instead of a Form Request class.app(...) helper (or new Class()) to resolve a dependency instead of injecting it.If you wrote one of these, fix it before requesting review. "Self-review before requesting review" is the single highest-leverage habit — multiple review rounds with the same class of issue means insufficient self-review, not a strict reviewer.
development
Use when reviewing code for security vulnerabilities, implementing authentication/authorization, handling user input, or discussing web application security. Covers OWASP Top 10:2025, ASVS 5.0, and Agentic AI security (2026).
development
Deep research before planning. Launches parallel agents to search docs, web, and codebase, then synthesizes findings into actionable context.
testing
Critically review and refine tests generated during a coding session. Use this skill when the user asks to review tests, clean up tests, refine tests, prune tests, improve test quality, or remove useless/low-value tests. Also use when the user says 'review my tests', 'clean up tests', or mentions test quality after a coding session.
development
Fix PHP coding style issues using PHPCS and PHPCBF. Use this skill whenever the user mentions PHPCS, code style, coding standard, cs:fix, cs:check, PHP formatting, or asks to fix/check PHP code style. Also activate when you notice PHP files have been modified and need style compliance, or when a CI PHPCS check has failed.