dave-liddament/php-language-extensions
PHP Language Extensions adds attributes for immutable classes, pure functions, read-only properties, and named parameters, with PHPStan rules to enforce them. Bring stronger safety and clearer APIs to PHP without waiting for new syntax.
Install it as a dev dependency — it ships attributes only, no runtime services:
composer require --dev dave-liddament/php-language-extensions
The important thing to internalize up front: the attributes do nothing on their own. They are inert metadata. Enforcement comes from static analysis — you wire the package's PHPStan extension (or Psalm plugin, depending on your analyzer) into phpstan.neon/psalm.xml following the README for your installed version, then run the analyzer. If your project has no static analysis today, adding this package means adding PHPStan or Psalm first.
Where to look first: the src/ directory for the attribute list, and the repo's own tests — they're the best usage documentation because each test asserts the exact analyzer error message you'll see.
First use case for a Laravel app is #[Override] (or #[Package] if you're already on PHP 8.3+ and want module boundaries). Start with the analyzer pointed at app/ only:
# phpstan.neon
includes:
- vendor/dave-liddament/php-language-extensions/extension.neon
parameters:
paths:
- app
Then annotate one directory:
use DaveLiddament\PhpLanguageExtensions\Override;
class UserController extends Controller
{
#[Override]
public function __construct(private UserRepository $users) {}
}
If the parent method is renamed or removed, the build fails. That's the entire value proposition: a cheap, incremental guardrail you can enable file by file.
Module boundaries for a modular monolith. This is the highest-leverage use in a Laravel codebase. Tag internals:
#[Package('App\Billing')]
final class InvoiceNumberGenerator { /* ... */ }
Any reference from outside App\Billing becomes an analyzer error. Keep deliberate public surface — events, jobs, DTOs other modules consume — untagged. App\Http, App\Providers, and App\Console are natural boundary-crossers; whitelist consciously rather than tagging everything and drowning in noise.
Immutable DTOs and value objects. Apply #[Immutable] to form objects, API response DTOs, money/quantity types, and action inputs. It catches the $dto->status = 'paid' assignment a controller snuck in. Do not apply it to Eloquent models.
Pure helpers. #[Pure] on formatters, calculators, casts, and Blade helper functions. It documents intent and flags a Log::info() or now() creeping into something called in a loop or a view.
Sealed hierarchies for strategies. When an interface has a fixed set of implementations (payment gateways, notification channels, export formats), sealing it makes "add an implementation" a deliberate change and keeps match expressions over the type exhaustive in the analyzer's model.
Package-private methods. On a service class, tag implementation-detail methods with #[Package('App\Orders')] so controllers can't reach past handle() into recalculateTotals().
Workflow. Give static analysis its own CI job (composer static-analysis) separate from tests, with its own cache. On a mature codebase use a PHPStan baseline (--generate-baseline) or Psalm baseline, then burn it down PR by PR. Pint and the analyzer are orthogonal — run both.
Composition with arch tests. Pest/PHPUnit arch() presets handle directory-level rules ("nothing in App\Http may use App\Billing\Internal"); attributes handle class-level intent that directory structure can't express. Use them together.
#[Package]. This is a CI gate, full stop. If CI can be skipped, the attribute is only documentation.#[Immutable]. Timestamps, mass assignment, dirty tracking, casts, and save() all mutate state through magic the analyzer can't model. Same caution for #[Pure] on anything touching config(), the container, Cache, or now().#[Override] vs. PHP 8.3's #[\Override]. Pick one. Don't mix them in one codebase; the package attribute only earns its keep if you support pre-8.3 PHP.__call/__get. Static analyzers see through Laravel's proxies poorly. Expect false negatives around fluent query builders and macros; don't let that erode trust in the rules that do work.vendor/, storage/, bootstrap/cache/, and IDE-helper output out of analyzed paths — but be careful an over-broad excludePaths doesn't swallow the plugin's own files (a common self-own that silently disables everything).#[Package]. Tests living in tests/Feature are outside your module namespace. Either mirror namespaces under tests/ or exclude tests/ from the package rule specifically (not from analysis entirely).require-dev is correct unless you reflect over these attributes at runtime (e.g., a custom container pass reading #[Package]). Production doesn't need the package.#[NoDirectRedis] are a weekend of work — and that's the most transferable thing this package teaches.How can I help you explore Laravel packages today?