A PHP settings class exposes config keys as properties through __get; what does that hide from IDEs and static analysers, and how would you redesign it?
answer
- names and types invisible to tools
- typos surface only at runtime
- @property-read and @method docblock tags
- typed readonly properties for known keys
- 8.4 property hooks for computed values
basics
~20 sMagic __get hides every property name and type from tools, so typos, renames and wrong types surface only at runtime. For known keys use typed readonly properties or 8.4 property hooks; if magic stays, document it with @property-read tags.
solid answer
~50 sWith `__get`, the class declares no properties, so an IDE cannot autocomplete `$settings->timeout`, a rename refactoring misses it, find-usages is blind, and a static analyser cannot know its type or flag `$settings->timout`. The typo is found only when that line runs. There is also runtime cost (a method call per access) and the `isset()`/indirect-modification traps. The redesign depends on the keys: if they are known, declare them as **typed, readonly promoted properties** and build the object from the config array in a named constructor; derived values can use **PHP 8.4 property hooks** or methods. If keys are truly dynamic, expose an explicit `get(string $key): mixed` or a typed accessor per group. When magic must stay, add `@property-read int $timeout` tags on the class, remembering that they are unchecked promises that can drift from the code.
go deeper
Know that __get properties are invisible to IDEs and that typos in their names are found only at runtime.
Explain the tooling and runtime costs of magic access, and how docblock tags partially restore the shape for tools.
Redesign magic-heavy classes into typed readonly properties or explicit accessors, and fail fast on unknown keys during migration.
Set a codebase policy for magic members, balancing framework conventions, analyser strictness and the cost of migrating legacy classes.
## What the tools can no longer see Static analysers and IDEs build a model of a class from its **declarations**: properties with types, methods with signatures. A settings class that serves keys through `__get` declares none of them: ```php final class Settings { public function __construct(private array $values) {} public function __get(string $name): mixed { return $this->values[$name] ?? null; } } ``` From the outside, `$settings->timeout` is an access to an **undeclared property**. The consequences: - **No autocomplete** and no go-to-definition: the key exists only in some config file. - **Typos compile.** `$settings->timout` is accepted by PHP and, with the `?? null` above, silently returns `null`. - **No types.** Every value is `mixed`, so `$settings->timeout * 1000` cannot be checked, and a string `'30'` from an environment variable flows through unnoticed. - **Refactoring is blind.** Renaming a key or finding its usages needs a text search across the codebase. - **Dead keys stay.** Nothing reports a key that no code reads any more. ## Runtime costs too Magic access is also different at runtime: 1. Every read is a **method call**, slower than reading a declared property. 2. `isset()` returns `false` unless `__isset` is implemented. 3. Writing into a nested array through `__get` has no effect without a by-reference `&__get`. ## Redesign options | Situation | Better design | What tools gain | |---|---|---| | Fixed, known keys | typed `readonly` promoted properties, built by `Settings::fromArray()` | names, types, autocomplete, rename safety | | Derived or validated values | PHP 8.4 **property hooks** (`get` hook) or plain methods | a declared property with logic behind it | | Genuinely dynamic keys | an explicit `get(string $key): mixed` plus typed helpers such as `int(string $key): int` | the call is visible and the magic is gone | | Magic must stay (legacy, generated) | `@property-read` / `@method` docblock tags on the class | declared shape for tools, not for the engine | A typical rewrite: ```php final class Settings { public function __construct( public readonly int $timeout, public readonly string $dbHost, ) {} public static function fromArray(array $c): self { return new self( (int) ($c['timeout'] ?? throw new InvalidArgumentException('timeout missing')), (string) ($c['db_host'] ?? throw new InvalidArgumentException('db_host missing')), ); } } ``` Now a missing key fails once, at construction, with a clear error; every consumer gets a typed, autocompleted property; and a rename is a refactoring, not a grep. ## Docblock tags are promises, not checks `@property-read int $timeout` and `@method int getTimeout()` above the class tell IDEs and analysers what the magic provides. They help, but: - The engine ignores docblocks entirely, so nothing checks that `__get` actually returns an `int` for `timeout`. - The tags drift when someone adds a key to the config and forgets the docblock. - They document; they do not validate input. Treat them as a bridge for code you cannot redesign yet. ## Migrating a magic settings class safely A settings object is usually read in many places, so a big-bang rewrite is risky. A staged path: 1. **Make unknown keys fail loudly first.** Change `__get` to throw on unknown names and run the test suite and a staging deploy; this flushes out typos that were silently returning `null`. 2. **Add `@property-read` tags** for every real key, so analysers start checking call sites immediately. 3. **Introduce the typed class** (`AppSettings` with readonly properties) built from the same config array, and let the old class delegate to it. 4. **Move call sites** to the typed class module by module, with the analyser flagging remaining magic reads. 5. **Delete the magic class** once nothing references it, instead of keeping it as a deprecated wrapper. ## Where magic still earns its place Magic is reasonable where the set of names is open by design: a wrapper forwarding to another object, or a record type generated from a schema at runtime. Even there, keep the surface narrow, throw on unknown names instead of returning `null`, and pair it with docblock tags or generated stubs so the rest of the codebase stays analysable.
- In PHP, why is returning null from __get for unknown keys worse than throwing?A typo such as `$settings->timout` then yields `null`, which flows into arithmetic, comparisons or database calls and fails far from the cause. Throwing an exception in `__get` for unknown names turns the typo into an immediate error at the line that made it.
- In PHP, does a @property-read tag on a class change what the engine does at runtime?No. Docblocks are comments to the engine. The tag informs IDEs and static analysers about the magic property's name and type, but nothing verifies that `__get` honours it, so the tag can drift from reality.
saying these in an interview costs you the question
- Believes @property tags make PHP enforce the property type at runtime
- Says magic properties cost nothing compared with declared ones
- Lets __get return null for unknown keys and calls it defensive
- Assumes IDE autocomplete works for __get keys without any declaration
- Rewrites every settings key as a separate __call getter to fix tooling