symfony/property-access
Symfony PropertyAccess reads and writes object and array values using simple string notation such as "user.address.street". It handles getters, setters, public properties, ArrayAccess, and magic methods, making nested data access generic and consistent across forms, serializers, and valida...
Architecture fit. This is a framework-agnostic Symfony utility component, not a Laravel package: no service provider, no facade, no config file, no publishable assets. It is a leaf dependency that does one thing — resolve a string path (user.address.city, items[0].sku, order.lines[2].qty) against an object/array graph for reads and writes, across getters, issers/hassers, public properties, __get/__set, ArrayAccess, and array keys. The gap it fills in Laravel is real but narrower than it first appears: data_get already handles arrays, ArrayAccess, and public object properties, but it does not traverse getters, magic accessors, or private typed state — which is exactly the shape of DTOs, value objects, read models, and third-party SDK payloads. Best-fit consumers: HTTP request → DTO mapping, webhook/payload normalization, view/read models, config and feature-flag path resolution, test fixture builders. Poor-fit consumers: Eloquent models (already ArrayAccess with their own accessor/mutator semantics — layering this on top creates a second, competing access model) and flat arrays (native helpers win on clarity and speed).
Integration feasibility. High. One composer require, one singleton binding, one thin adapter with two methods. The public surface is small and stable in shape: PropertyAccessor (getValue/setValue/isReadable/isWritable, plus the invalid-index and magic-call toggles), PropertyPath for parse/validate, and the exception types (NoSuchPropertyException, NoSuchIndexException, UnexpectedTypeException). Note the distribution model: this repo is a read-only split of the Symfony monorepo — issues, PRs, and security advisories are handled in symfony/symfony, so GitHub stars and "dependents" here say nothing about adoption or health. Verify the transitive delta against the lockfile before committing; the component sits inside the Symfony component graph and can pull siblings you do not otherwise carry.
Technical risk. Four items to fund mitigations for:
void/never, PHP 8.4 asymmetric visibility, cached null read info. The answer to "what does getValue($o, 'foo') actually call?" can therefore change in an x.y.Z bump. Treat resolution order as an assumption to assert in tests, not documented behavior to rely on. Pin exact versions; review patch bumps.^6.4|^7.0|^8.0 constraint silently raises the runtime floor for the whole app. Constrain to the branch matching our PHP/Laravel matrix.getValue($user, $userSuppliedPath) is a data-exfiltration path (hidden attributes, tokens, relation traversal) and setValue is mass assignment. If paths can ever originate from saved filters, admin rules, or request payloads, this needs an allow-list before it ships.user.profile.avatar throws when profile is null. Any adapter that does not convert misses into a domain-level default will produce 500s on ordinary sparse payloads.Also worth naming: on Eloquent, __get is the attribute accessor, so getValue($model, 'remember_token') returns the token regardless of $hidden (which only affects serialization) — and traversing customer.address.city silently triggers lazy loads.
Key questions.
data_get-plus-getter-chain hybrid, or nothing?Stack fit. Install as a leaf dependency, bind PropertyAccessor as a container singleton (it caches reflection read/write metadata per instance — constructing one per call throws that away), and expose it only through an internal interface, e.g. Reader::read(object|array $target, string $path, mixed $default = null): mixed and a separate Writer. Keep Symfony types out of application-level signatures (controllers, jobs, Blade) so the component is swappable in one file if it disappoints. Do not use it on Eloquent models; if a path must cross a model, resolve the model's data through its own API first.
Migration path. Inventory existing traversal code before adopting — grep for chained getters, property_exists, ReflectionProperty, ReflectionClass, and data_get calls on objects — so you are sizing a cleanup, not just an install. Then pick exactly one bounded context, almost always read-only DTO/resource mapping: request-scoped, easy to test, easy to feature-flag, no write-side blast radius. Migrate that consumer, delete the bespoke code it replaced, and only then consider writes.
Compatibility. Constrain to the Symfony branch matching our PHP floor (6.4 and 7.4 are LTS branches; 8.x is standard-track and PHP 8.4+). Confirm the component's PHP requirement against the Laravel version matrix we actually run. Because Laravel does not test against this component, we own the integration contract: add a small compatibility test that exercises read, write, miss, and a magic-accessor case against the pinned version, and run it on every dependency bump.
Sequencing.
PropertyAccessor singleton, exception→default translation, path convention doc.Maintenance. Low code volume, non-trivial discipline. The component itself is stable; the churn is in patch releases that adjust resolution order, so upgrades should be treated as behavior changes: pin, read the changelog, run the compatibility test, then bump. Security advisories arrive through Symfony's process (and composer audit), not this repo — ensure the pipeline watches the monorepo advisory feed. Ownership is internal: assign a named team for the adapter, the path convention doc, and the allow-list. Symfony community support is best-effort via the monorepo issue tracker; no vendor SLA at this tier.
Support. Expect two classes of internal tickets: "path returns null / throws unexpectedly" (usually sparse data or an accessor-name mismatch) and "why didn't my setter/mutator run" (write path bypassing domain logic). Both are prevented by documentation and by the adapter's default-on-miss behavior, not by runtime fixes. Budget ~1 day of onboarding per engineer touching it; the component is small, the semantics are the hard part.
Scaling. CPU cost is negligible. The real scaling risks are graph-shaped:
getValue($order, 'customer.address.city') in a loop is an N+1 generator. Lint paths used in loops; require eager loading where paths cross relations.isReadable() on its target.Failure modes.
NoSuchPropertyException → unhandled 500. Mitigation: adapter returns the supplied default and logs at debug.__get doing work, proxies initializing, lazy loads firing) → surprising queries or side effects on read.enableMagicCall() combined with user-influenced paths → arbitrary method invocation on our objects. Keep magic call disabled by default and treat enabling it as a reviewed decision.password_hash, remember_token, hidden attributes) via path strings — $hidden does not protect direct access, so treat any non-literal path as untrusted input.Ramp-up. Small engineering lift, modest process lift. One engineer can deliver the adapter plus the first consumer in a week; the ongoing cost is the review discipline — pinned versions, allow-listed paths, no Eloquent targets, adapter-only access. Recommendation: proceed on that scope, and treat write support and user-supplied paths as separate, later decisions with their own review.
How can I help you explore Laravel packages today?