Code style and comments
Read when: writing or reviewing any PHP, and always before calling a change done (the comment check).
Owns: formatting, one-line signatures, named arguments, the comment and PHPDoc policy and the comment
check, naming, types, readonly, imports, morph type values, private methods over single-use classes.
TL;DR
- Pint owns formatting. Run
vendor/bin/pint --dirtyon the files you changed; never hand-align. - Method signatures stay on one line, however long. Only DTO constructors go one property per line.
- Named arguments when a call passes a bare literal (
true,null, a number) or two or more values of the same type. - Write few comments; default to none. A comment says a WHY a competent reader would get wrong without it, in 1–2 lines. Delete anything that restates code or narrates history.
- PHPDoc only for what a signature cannot say: array shapes, generic collections, meaningful
@throws. No docblocks on top of classes or methods that restate them. Never@inheritdoc. - A comment above a declaration is a
/** … */docblock;//lives only inside a method body. - A step that one class needs stays a private method there; a new class needs real reuse.
- Types on every parameter and return;
?Type, notType|null.readonlywherever nothing changes.
Formatting
vendor/bin/pint --dirtyformats only modified files. Run the fullvendor/bin/pintonly when asked; a repo-wide format buries the real diff. CI runsvendor/bin/pint --testand fails on any change it would make.- If Pint's output is wrong for the project, change
pint.json, not the code around it. Start from thelaravelpreset and add only the rules the team decided. [], neverarray(). Single quotes unless interpolating ("Invoice {$number} issued").- Curly braces on every control structure, even a one-line body.
- One class per
use; Pint orders them; no unused imports. - New models declare casts in a
casts()method, not the$castsproperty. Leave existing ones until that model is refactored.
One-line signatures (deliberate)
// ❌
public function getInvoice(
?int $id = null,
?FilterInvoiceData $filters = null,
array $withRelations = []
): ?Invoice;
// ✅
public function getInvoice(?int $id = null, ?FilterInvoiceData $filters = null, array $withRelations = []): ?Invoice;
Why: every public method then scans the same way, and a diff of a signature is one line. The exception is a DTO constructor: one promoted property per line.
Named arguments at call sites
Name the arguments when a call to your own code passes:
- a bare literal (
true,false,null, a number), whose meaning the call cannot show; - two or more values of the same type, which could be swapped without any error.
// ❌ what is `true`, and which model is the customer?
new InvoiceFormProps($invoice, true, $customer, $employee);
// ✅
new InvoiceFormProps(invoice: $invoice, isCreate: true, customer: $customer, preparedBy: $employee);
A call with one obvious argument (getInvoice($id)) or a framework helper (abort(404),
route('invoices.show', $id)) stays positional. Why the limit: a named call ties the caller to the
parameter name, so renaming the parameter means updating its named callers.
Comments
The bar
Would a competent reader get this wrong without the comment? If not, delete it. A comment earns its place when it records a constraint, an invariant, a workaround, or a quirk of something outside the code.
// ✅ a WHY nobody could guess
// The provider returns 502 for a few seconds after a deploy; treat it as a soft failure.
if ($response->status() === 502) {
return null;
}
What to delete
- Restating the code.
// get the invoiceabovegetInvoice(); a docblock that repeats a method or constant name in a sentence. - Narrating steps. One comment per non-obvious decision, not one per line.
- History. "Changed from X", "added after the bug in Y", ticket numbers with their story: that belongs in the change description. A live constraint is fine ("keep while app v2.3 still sends this").
- Repetition. State a rationale once, where it is enforced, not at every call site.
- Explaining the language or naming what the next line does.
- Docblocks that restate the signature:
// ❌
/**
* Get an invoice by id.
* @param int $id The invoice id
* @return Invoice|null The invoice or null
*/
public function getInvoice(int $id): ?Invoice
// ✅ the signature is the documentation
public function getInvoice(int $id): ?Invoice
- Comments on self-evident code. Filter scopes,
when()chains, guards, getters, mappers and early returns read as they are. A long filter scope with zero comments is the target, not a gap.
// ❌
->when(
// only when the caller sent the flag, since false is a real value
! is_null($filters?->is_paid),
fn (Builder $q) => $q->where('is_paid', $filters->is_paid)
)
// ✅
->when(! is_null($filters?->is_paid), fn (Builder $q) => $q->where('is_paid', $filters->is_paid))
- Long comments. More than two lines means a better name or an extracted, well-named method is due.
Rules of placement
- Above a method, property, constant or class:
/** … */, never//. - Inside a method body:
//. - Rewriting code means rewriting (or deleting) its comments. A stale comment is worse than none.
- Cite a decision record by its number instead of restating its reasoning, if the project keeps them.
The check before calling a change done
- Read your diff and list every comment or docblock line you added.
- For each, ask: what would go wrong without it?
- No answer → delete it. Keep only what has one.
Leave pre-existing comments alone unless asked; they are not part of your change.
When PHPDoc is needed
/** @param InvoiceLineData[] $lines */
public function __construct(public readonly int $customer_id, public readonly array $lines) {}
/** @return Collection<int, Invoice> */
public function overdue(): Collection
/** @throws InvoiceAlreadyIssued */
public static function handle(Invoice $invoice): Invoice
- Element types of an
arrayparameter or property (shapes:array{id: int, name: string}). - Generic collection types for static analysis.
@throwswhen the exception is meaningful to the caller.
An array parameter without a shape is a review finding; usually it should be a DTO instead.
Naming
| Thing | Case | Example |
|---|---|---|
| Classes, enums, interfaces | PascalCase |
IssueInvoice, InvoiceContract |
| Methods, variables, parameters | camelCase |
getInvoice(), $invoiceId |
| Constants | UPPER_SNAKE_CASE |
MAX_LINES |
| Enum cases | the project's one choice | PAID or Paid, never both in one codebase |
| DTO properties | snake_case (deliberate carve-out) |
$customer_id |
| Files | match the class exactly | IssueInvoice.php |
- Names say what a thing is or does:
isRegisteredForDiscounts, notdiscount(). - Booleans read as questions:
isLocked,hasLines,wasRefunded. - In a layered layout, suffixes say the role: single-operation classes have none (
IssueInvoice), DTOs end inData, interfaces inContract, their implementations inService.
Types
// ❌
public function getInvoice($id) {}
public function findInvoice(int $id): Invoice|null {}
// ✅
public function getInvoice(int $id): ?Invoice {}
- Every parameter and return typed.
?Typefor nullable; real unions (int|array|nullon a filter) are fine. voidandneverwhere they apply.mixedonly at a true boundary, narrowed right away.
readonly
DTOs always. Value objects nearly always. Controllers and other classes whose only properties are
injected dependencies: private readonly promoted properties. A class with no state at all can be
final readonly.
Morph type values
When the app has no morph map, write type values as Invoice::class. Never instantiate a model to read its
name ((new Invoice)->getMorphClass()). On an instance you already hold, $model->getMorphClass() is fine
and is correct either way. If the app enforces a morph map, use the alias through getMorphClass() or the
map, never a hard-coded class string.
Private methods over single-use classes
// ❌ a class whose only caller is one operation class
final class FormatReminderLines
{
public static function handle(Invoice $invoice): array { /* … */ }
}
// ✅ a private step next to the code that uses it
final class SendInvoiceReminder
{
public static function handle(Invoice $invoice): void
{
$lines = self::reminderLines($invoice);
// …
}
private static function reminderLines(Invoice $invoice): array { /* … */ }
}
A class earns its own file through reuse (more than one caller). Readability alone is a reason for a better private method, not a new class. Raise it before adding one. This does not change how operations are split into classes (one per create, update, issue…); it is about the steps inside one.
Static analysis
Run Larastan / PHPStan at the highest level the codebase passes, and raise it over time. Conventions that
can be checked statically belong in a rule (custom PHPStan rule or Pest arch()), not only in prose.
Verify
These find existing code too. Treat the output as a review list scoped to what you changed.
# Multi-line method signatures
grep -rnE "function [a-zA-Z_]+\(quot; src/ app/ --include='*.php'
# @inheritdoc
grep -rni "@inheritdoc" src/ app/
# // comments directly above a declaration
grep -rnE -A1 "^\s*// " src/ app/ --include='*.php' | grep -E "^\S+-[0-9]+-\s*(public|protected|private|final|class|const|function)"
# Hard-coded morph class strings
grep -rnE "_type'\s*=>\s*'[A-Z][A-Za-z\\\\]+'" src/ app/
# Instantiating a model only for its morph class
grep -rn "(new [A-Z][A-Za-z]*)->getMorphClass()" src/ app/