Skip to content

Conventions

Rules about how the code is written, as opposed to what it must do. The rules that break the product live in the invariants; these break the codebase slowly instead, which is why they are written down rather than left to whoever reviews.

Each is checked at review. Where a rule can be checked by a machine, it is, and that is noted.


1. Laravel first

This package is a Laravel package. Before writing a helper, check whether the framework already has it, and use that.

The package requires illuminate/support, illuminate/http, illuminate/process and illuminate/filesystem outright. Everything in them is already installed, already tested, already documented, and already familiar to the person reading the code. A private reimplementation of any of it is code this project has to maintain, test and explain, in exchange for nothing.

This is a rule, not a preference. It is checked at review, and part of it is checked by tests/Project/ArchTest.php.


The rule

  1. Look in the framework first. Illuminate\Support\Str, Arr, Collection, Facades\File, Facades\Process, Facades\Http, Facades\Config, Facades\Cache, the Illuminate\Contracts\* interfaces.
  2. If it exists there, use it, even when the native call is two characters shorter.
  3. If it does not, write it, put it in src/Support/, and say in the docblock what the framework does not provide. A helper whose docblock cannot answer "why is this not Str::something" is a helper that should not exist.

Exceptions are below and they are narrow. Everything not listed there follows the rule.


Reach for

Instead ofUseWhy
file_get_contents, file_put_contentsSupport\Files::read(), File::put()Files::read() exists because both the native call and File::get() return false, and that false reaching a string parameter was this package's most common typing defect
is_dir, mkdir, unlink, globFile::isDirectory(), File::makeDirectory(), File::delete(), File::glob()one filesystem abstraction, fakeable in a host application's tests
uniqid, random_bytes for a nameStr::orderedUuid(), Str::random()already how Support\TemporaryFile names its files
exec, shell_exec, proc_openSupport\ProcessRunner on Illuminate\Processinvariant 8, and Process::fake() in a consuming application
curl_*, stream_context_create + file_get_contentsIlluminate\Support\Facades\Httptimeouts, retries and Http::fake(), instead of a hand-rolled stream context
array_map / array_filter / array_merge chained over one valuecollect()one pipeline instead of three nested calls, when it genuinely reads better
a hand-written get($array, 'a.b.c')Arr::get()
reading config with a cast and a defaultIlluminate\Contracts\Config\Repository, injectedalready how the package reads every configuration key
a hand-rolled toArray() on a value objectIlluminate\Contracts\Support\ArrayableData\BaseData already implements it

Do not reach for

These are the narrow exceptions, and each is load-bearing.

Keep the native callWhy
substr, strlen, strpos, str_replace on PDF or DER bytesStr::substr() and Str::length() are multibyte-aware. Running them over a PDF or a CMS reinterprets binary as UTF-8 and returns the wrong offsets, which in this package means a corrupted signature. Byte work uses byte functions, always
preg_match, preg_match_allStr::match() returns the match and throws the offsets away, and offsets are what the incremental writer is built on. Str::isMatch() is fine where only the boolean is wanted
openssl_*the framework wraps none of it
pack, unpack, bin2hex, hex2bin, gzuncompressno framework equivalent, and all byte-exact
hash(..., binary: true)Hash:: is password hashing, a different thing entirely

The first row is the one that matters. If a change swaps a byte-level substr for Str::substr, it will pass every test in this suite on ASCII fixtures and corrupt real documents in production.

Enforced by tests/Project/ArchTest.php, which fails when Illuminate\Support\Str is used inside src/Signing or src/Validation at all: those namespaces are where the byte work lives, and the rule is easier to keep as "not here" than as "here, but only these methods".


Known outstanding

Signing\Cades\HttpTransport builds its own stream_context_create and calls file_get_contents for the TSA, OCSP and CRL requests. Http:: is the right tool and guzzlehttp/guzzle is already in the tree, so this is a gap in the rule rather than an exception to it. It is called out here rather than left for someone to find, and moving it also makes the network surface fakeable, which is the same argument that put ProcessRunner on Illuminate\Process.

Rationale and alternatives: 0018.


2. Enums, not class constants

A closed set of values is an enum. A class constant is for the case where exactly one value can ever exist, and for nothing else.

PHP has had enums since 8.1 and this package's floor is 8.4, so a set of related constants is a type the language will check for you that has been written as a set of integers it will not.

WriteInstead of
enum SignatureProfile: stringconst PADES_B_B = 'pades-b-b' beside four siblings
enum CertificationLevel: stringconst NO_CHANGES = 1, const FORM_FILLING = 2, …
enum Asn1Tag: intconst SEQUENCE = 0x30, const SET = 0x31, …

A constant stays a constant when it is a lone fact about the world rather than one of several choices:

Legitimate constantWhy
CertificateVault::CIPHERone cipher, chosen once
IncrementalSigner::CONTENTS_HEX_LENGTHone reserved width
Pem::CERTIFICATE_MARKERone string, fixed by RFC 7468
ByteRangeCalculator::FIELDone placeholder shape
LaravelA1PdfSignServiceProvider::CONFIG_PATHone path
XrefStreamWriter::WIDTHSone column layout, fixed by §7.5.8

The test is not "is it private" or "is it an array". It is "could a second value of this kind ever be right?" If yes, it is an enum today, because the sibling arrives later and arrives as a constant beside the first one.

Enums that are not configuration may be int-backed

tests/Project/ArchTest.php requires enums in Enums\ to be string-backed, so a configuration file can name a case in plain text. That reason does not reach an enum nobody configures, like an ASN.1 tag whose values are fixed by ISO/IEC 8825-1 and are natural integers. Those are exempt by name in the arch rule, the way sha1 is exempt for SignatureDetails, rather than by weakening the rule for every enum.

Known tension

Data\SealPlacement::LAST_PAGE is an int sentinel of -1, and by this rule it would be an enum. It was one: Enums\SealPage existed and was removed during the v2 work on the grounds that "the page is one field of a placement, not a concept with its own behaviour" (the modernisation record).

That reasoning predates this rule and is not obviously wrong, and reversing it now would change the type of a public property. It stays as it is, named here so the next person finds a decision rather than an oversight.


3. A docblock documents the thing under it

Two failures, both of which shipped, both now checked by tests/Project/ArchTest.php rather than left to review.

Never leave two docblocks in a row

php
/**
 * The signature applied last, which is the only one covering the whole file.
 */
/**
 * The archive timestamps, which are reported separately from signatures.
 */
public function timestamps(): array

That is real code from Data\SignatureReport. A method was inserted between a docblock and the method it described, so the first block ended up attached to the newcomer and latest() was left undocumented. Every tool that reads docblocks then reports the wrong thing about two methods, and the diff that caused it looks like a pure addition.

Found four times across src/ and tests/ the day the rule was written.

When adding a method next to an existing one, put the new docblock above the new method, not above the old one. When a docblock and a @param block end up separated, merge them into one block; PHP associates only the last.

Never leave a @param naming a parameter that is gone

The other half of the same problem: the signature moved and the prose did not. A docblock that documents nothing is a comment nobody reads. A docblock that documents the wrong thing is worse than no docblock, because it is believed.

Every file declares strict types

declare(strict_types=1); at the top of every PHP file in src/, tests/ and config/. Not optional, and not a preference.

A package that signs documents does arithmetic on byte offsets constantly, and without it substr($pdf, "12") and str_repeat('0', 8.9) are coerced in silence. Both produce a file that is subtly wrong rather than one that fails, which is the worst outcome available to a signature.

The blast radius is smaller than it sounds, and worth knowing. Strict types are decided by the calling file, so a consuming application that does not declare them keeps its own coercion when it calls this package. What becomes strict is this package calling itself, and this package calling PHP.

It was switched off deliberately until 2026-08-12: pint.json carried "declare_strict_types": false and not one of the 169 files declared it. Turning it on changed no behaviour, and the whole suite passed unmodified, which says the code was already written as though it were on.

Enforced by pint.json, which writes the declaration, and by tests/Project/ArchTest.php twice: an arch expectation over src/, and a file walk for tests/ and config/, where arch expectations cannot reach because those files declare no classes. poc/ is out of scope, as it is for Pint and PHPStan.

Never cite a file that does not exist, and write it first

A comment, docblock or document may only name a path that resolves at the moment it is written. Not "will exist when the record is written up", not "exists on the branch that has not landed": now.

The record comes first. When a change wants a decision record or a specification section, that file is created before the code referring to it, in the same change and earlier in it. The reverse order produces a reference to something nobody wrote, and the code then documents an argument that was never made.

This is not hypothetical and it is not other people's mistake. A comment in Signing\IncrementalSigner was written citing a decision record numbered 0034, about holding the document once, while the fix it described was still being measured. The record was never written, the reference stayed, and the only reason it did not ship is that tests/Project/SpecTest.php refused the commit.

The first draft of this very section quoted that path in full, to illustrate the rule, and the gate refused that too. Which is the right outcome: a scanner cannot tell an example of a bad reference from a bad reference, and a rule whose own text has to be exempted is a rule with a hole in it. Describe the missing file; do not spell it.

Enforced by tests/Project/SpecTest.php, which walks every .php, .md, .yml and .yaml file in the package and resolves every documentation path any of them cites. It is a gate rather than a review point, and it is the reason this rule can be stated so flatly.

Symbols are checked too, on the same terms: a comment naming a class or a Class::member of this package must resolve. Restricted to LSNepomuceno\LaravelA1PdfSign, because prose legitimately names other people's classes, PHP functions and PDF syntax that looks like neither, and one comment has to be able to name ddn/sapp precisely because it is the thing deliberately absent. Within this package's own namespace the question is mechanically decidable and needs no allowlist.

Only comment and docblock text is read from a PHP file: a namespace or use line is code, already answered by the autoloader and by PHPStan. docs/history/ and UPGRADE.md are exempt, since recording what the package used to be is their whole job.

What is deliberately not checked

Whether the prose is true. No tool can, which is why the rules above are narrow: they catch the failures that are mechanical, and leave the rest where it belongs, with whoever changed the code.

Version undefined. Released under the MIT License.