kdyby/strict-objects
kdyby/strict-objects provides a PHP trait that makes objects strict: reading or writing undeclared properties throws an exception instead of silently creating dynamic ones. It catches typos and enforces explicit property declarations in Nette projects.
StrictObjects is a single trait that turns PHP's silent "undefined member" behaviour into loud exceptions. It exists because PHP (and frameworks like Nette) will happily swallow $obj->undefinedProperty (returns null / triggers a notice) and $obj->undefinedMethod() (fatal, or a magic __call swallowing it), which hides typos and refactoring mistakes.
Minimum steps:
composer require kdyby/strict-objectssrc/StrictObject.php — it's ~50 lines; you'll understand the whole package in one sitting. Then glance at MemberAccessException / MemberCallException in src/.use Kdyby\StrictObjects\StrictObject;
final class Money
{
use StrictObject;
public function __construct(public int $amount, public string $currency) {}
}
$money->amoutn (typo) or $money->format() (missing method). Before the trait, you get null and a bug in production; after the trait, you get a MemberAccessException / MemberCallException at the exact call site.This is the whole point: fail fast on typos, no magic, no configuration, no service provider, no DI extension.
The trait cannot be used alongside real magic methods. That is the core constraint and the core pattern. StrictObject defines __get, __set, __isset, __unset, __call (and __callStatic). If your class already relies on __get/__call for lazy loading, proxies, or Nette's Component model, do not put the trait there — you'll break the magic and throw on legitimate calls.
Where it belongs:
abstract class AggregateRoot { use StrictObject; }. Every subclass inherits the strictness for free.$this->repositroy instead of $this->repository becomes a MemberAccessException instead of a null cascade.Wrapping a legacy class that has some magic but you want strictness on the rest: use composition, or accept that this trait is mutually exclusive with your existing __get. You can also define __get yourself and delegate only what you need, then call StrictObject behaviour explicitly — but at that point just throw your own MemberAccessException; the trait's value is the "I have no magic, and I mean it" declaration.
Static side: if your trait version includes __callStatic, calling an undefined static method (MyClass::fromMoney() when it's on an instance) becomes a MemberCallException rather than a fatal error. Useful in test suites where you want a clean assertion target.
Integration tips:
services entry. It's zero-config.final classes only, ideally. An inherited __get from a non-strict parent class will shadow the trait's if the trait is not on that parent — make sure the trait is on the class whose __get would otherwise be hit. If a parent class defines __get and the child uses the trait, the child's trait method wins (traits beat inherited methods), which is usually what you want, but confirm with a quick unit test.Testing pattern: assert the exception type, not the message. MemberAccessException and MemberCallException both extend \LogicException, so expectException(\LogicException::class) is a stable contract; pinning exact message strings is brittle across releases.
use another trait that defines __get, __set, or __call (common in framework-provided traits), PHP throws a fatal trait-collision error at class load. Resolve with use StrictObject, OtherTrait { StrictObject::__get insteadof OtherTrait; } or, better, don't combine them.isset() and empty() now throw. This is the most surprising behaviour in practice. isset($entity->relation) on a strict object throws MemberAccessException rather than returning false. Any code that probes members defensively — Laravel/Nette templates, generic serializers, array_map(fn($k) => $obj->$k, [...]) — will now explode. Check templates and reflection-ish generic code before applying the trait broadly.property_exists() and get_object_vars() still work (they bypass magic methods), so hydration/un-hydration code generally survives. get_object_vars() returns only declared, initialised properties, which is exactly the intent.__call differently: $obj->foo(name: 1) calls __call('foo', ['name' => 1]) — the exception still fires correctly, but if you override __call for anything, remember the args array can be associative.composer.json before installing on PHP 8.2+/8.3/8.4 — check the php constraint. The code is trivial enough that a lockfile mismatch is the only realistic compat problem; if the constraint is too tight, fork or vendor the 50 lines.NOASSERTION on the registry. The repo declares its license (check composer.json / LICENSE); confirm it against your org's policy before adding it to a commercial codebase — the automated score gives you no guarantees here.null.__get only fires for inaccessible/undefined properties; a declared-but-typed property accessed before initialisation still throws PHP's own Error, not MemberAccessException. Don't write a catch around StrictObject exceptions expecting to catch uninitialised-typed-property errors — those are separate.DomainException or to report to Sentry, catch \Kdyby\StrictObjects\MemberAccessException at a boundary layer rather than editing the trait.isset() gotcha. Start with value objects and DTOs, expand as you gain confidence.How can I help you explore Laravel packages today?