tbn/getter-trait-bundle
tbn/getter-trait-bundle provides reusable PHP traits for adding getter methods to your classes. It reduces boilerplate, keeps property access consistent, and works smoothly in Laravel or standalone PHP projects.
composer require tbn/getter-trait-bundle
Then, if it is shipped as a Symfony bundle (check vendor/tbn/getter-trait-bundle/src/ for a *Bundle.php class):
// config/bundles.php
return [
Tbn\GetterTraitBundle\GetterTraitBundle::class => ['all' => true],
];
Three files to read first, in this order:
src/Trait/GetterTrait.php or similar) — this is the entire runtime behaviour and tells you exactly which method names map to which properties.src/Annotation/Getter.php or src/Attribute/Getter.php) — confirms whether it targets classes, properties, or both, and whether it is #[Attribute] (PHP 8) or a Doctrine annotation.src/DependencyInjection/ — if the trait is pure runtime magic, the extension just registers services; if it does compile-time code generation, this is where the work happens and where you will need to clear cache after config changes.First use case — a read-only DTO instead of hand-written accessors:
use Tbn\GetterTraitBundle\Annotation\Getter;
use Tbn\GetterTraitBundle\Trait\GetterTrait;
#[Getter]
class UserDto
{
use GetterTrait;
private string $firstName;
private string $lastName;
private ?\DateTimeImmutable $createdAt;
public function __construct(string $firstName, string $lastName, ?\DateTimeImmutable $createdAt = null)
{
$this->firstName = $firstName;
$this->lastName = $lastName;
$this->createdAt = $createdAt;
}
}
$dto = new UserDto('Ada', 'Lovelace');
echo $dto->getFirstName(); // "Ada"
If getFirstName() resolves, the trait is wired correctly and you can stop reading the docs. If it throws Error: Call to undefined method, the attribute is probably not being applied — check namespace, whether the attribute is repeatable, and whether the bundle's compiler pass ran (bin/console cache:clear first).
Attribute-per-class vs. attribute-per-property. If the attribute targets properties, the usual idiom is opt-in on the class plus opt-out on individual properties that should stay opaque (passwords, internal IDs, mutable collections). Decide this once and apply it consistently, because mixing generates a class where some properties have getters and others silently don't — which is worse than either option alone.
Pair with constructor property promotion for value objects. The strongest fit for a magic getter trait is immutable DTOs and value objects where the constructor is the only writer:
#[Getter]
final class Money
{
use GetterTrait;
public function __construct(
private readonly int $amount,
private readonly string $currency,
) {}
}
No setters exist, nothing can mutate, and the trait removes the boilerplate that normally makes people give up on value objects.
Docblock @method blocks are not optional. This is the pattern that separates a working integration from a painful one. Every class using the trait should declare its generated surface so PHPStorm, Psalm, and PHPStan can resolve it:
/**
* @method string getFirstName()
* @method string getLastName()
* @method \DateTimeImmutable|null getCreatedAt()
*/
#[Getter]
class UserDto { use GetterTrait; /* ... */ }
If the package ships a PHPStan/Psalm extension or a static-analysis generator, wire it in phpstan.neon early — retrofitting it across fifty DTOs later is tedious. If it does not, consider generating the docblocks with a small custom command; it pays for itself quickly.
Entities: read-only exposure only. On Doctrine entities, applying the trait to scalar columns is safe. Avoid using it to expose associations if you rely on lazy-loading semantics in serializers, because a __call-dispatched getter bypasses any explicitly defined logic (e.g. filtering a collection before return). Where you need logic, write the real method — a concrete method always wins over __call in PHP, so mixing real and magic getters in one class is safe and is the recommended escape hatch.
Integration with Symfony Serializer. ObjectNormalizer discovers getters via reflection/method_exists(), which returns false for magic methods. If you serialise these DTOs to JSON, either (a) configure the normalizer with setAccessorMethodPrefix/property access and rely on PropertyAccess reading private properties via reflection, or (b) add #[SerializedName]/@Groups on properties and use a normalizer configured for direct property access. Test one json_encode($dto) through the serializer before committing to the pattern.
Test fixtures and factories. The trait is genuinely useful in test doubles and fixture builders where you want to assert on state without declaring ten accessors per fixture class. It is not a good fit for classes you mock — PHPUnit cannot mock __call-provided methods, so keep the trait off anything that appears in a createMock() call.
Wrapping in a base class. If the bundle does not already provide one, a project-level abstract class AbstractDto { use GetterTrait; } centralises the trait and the @method generation strategy, and gives you one place to change if you later swap the package out.
method_exists() and is_callable() disagree. method_exists($obj, 'getName') is false for magic getters; is_callable([$obj, 'getName']) is true. Any generic code (serializers, reflection-based mappers, form data mappers) that probes with method_exists() will skip these classes. This is the single most common source of "why is my DTO empty in the JSON response" bugs.
Typos fail late and quietly. A call to getNmae() (or a property that has no getter because the attribute did not reach it) throws at runtime, not at compile time. Static analysis with the @method docblocks is the real defence; without it, this trait trades boilerplate for runtime fragility.
Uninitialised typed properties throw Error, not null. getFoo() on a private string $foo; that was never assigned produces Typed property ... must not be accessed before initialization. This bites hardest in Doctrine entities hydrated partially (e.g. PARTIAL DQL selects). Prefer nullable or defaulted properties on anything that may be partially hydrated.
Boolean naming. Most such traits expose isFoo() for bool $foo in addition to or instead of getFoo(). Verify which — if your templates already call entity.enabled (property access via PropertyAccess) versus entity.isEnabled, you can end up with two code paths doing the same thing. Pick one and enforce it in review.
__call collisions. If the class (or a parent, or another trait) already defines __call, PHP fatals on the trait conflict unless you use insteadof/as. Symfony's __call on Controller subclasses, and any existing magic-method layer (proxies, decorators), are the usual culprits. If the trait defines __get as well, watch for interaction with Doctrine\ORM\Proxy\Proxy and with stdClass-style hydration.
Bundle registration is easy to forget on upgrades. With two stars and zero dependents, treat every release (the last was 2025-09-19) as potentially breaking. Pin the version in composer.json ("tbn/getter-trait-bundle": "~1.0" or the exact tag you validated) and read the diff before bumping. There is essentially no community safety net here — no StackOverflow corpus, no downstream users to discover a regression first.
Cache and compiler pass quirks. If the bundle generates code or builds a map of classes in a compiler pass, changes to attributes are not picked up until bin/console cache:clear (and in dev, cache:pool:clear cache.app if the map is cached there). A trait that "sometimes works" after editing attributes is almost always a stale container.
Property naming convention. Magic getters normally strip get/is/has and lowercase the first character, so getURL() maps to $uRL, not $URL. Acronym properties are a classic trap — declare them as $url or write a real getter.
Extension points to check before you fork. Look for an interface on the trait's method-resolution logic, a configurable prefix list, or a compiler-pass tag that lets you register additional naming strategies. If none exist, the practical extension point is a project-level base class that overrides __call and delegates to the trait's logic — that is your only seam.
readonly and final interact cleanly, private in parent does not. A trait's magic getter can read private properties declared in the same class that uses the trait, but not private properties declared in a parent class. If you build an inheritance hierarchy of DTOs, declare shared properties protected or re-declare the trait in each concrete class.
One smoke test is worth more than the docs. Add a single test that instantiates each trait-using class and calls one generated getter and asserts the value. It catches unregistered attributes, stale caches, and naming regressions in one shot, and given the package's maturity level, you should not rely on upstream tests for this assurance.
How can I help you explore Laravel packages today?