PHPackages                             survos/field-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Search &amp; Filtering](/categories/search)
4. /
5. survos/field-bundle

ActiveSymfony-bundle[Search &amp; Filtering](/categories/search)

survos/field-bundle
===================

Universal field/property metadata attributes for search, grid, and AI — #\[Field\], Widget enum, and FieldReader.

2.7.2(1w ago)0236↑137.5%10MITPHPPHP ^8.4

Since Apr 27Pushed 2d agoCompare

[ Source](https://github.com/survos/field-bundle)[ Packagist](https://packagist.org/packages/survos/field-bundle)[ GitHub Sponsors](https://github.com/kbond)[ RSS](/packages/survos-field-bundle/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (16)Versions (43)Used By (10)

survos/field-bundle
===================

[](#survosfield-bundle)

Universal field/property metadata for Symfony — declare once, consume everywhere.

The problem
-----------

[](#the-problem)

Property metadata is scattered across attributes with overlapping concerns:

AttributeOwnerCovers`#[ApiProperty]`api-platformOpenAPI description, example`#[With]`symfony/aiJSON Schema constraints for LLMs`#[ORM\Column]`doctrineStorage type`#[ApiFilter]`api-platformServer-side filter declarationNone of them answer: *how should this property be displayed and filtered in a grid, search panel, or UX-search widget?*

The solution
------------

[](#the-solution)

`#[Field]` declares display and search behavior once, orthogonally to the other attributes:

```
use Survos\FieldBundle\Attribute\Field;
use Survos\FieldBundle\Enum\Widget;

class Tenant
{
    #[Field(searchable: true, sortable: true, order: 10)]
    public string $name = '';

    #[Field(filterable: true, widget: Widget::Select, facet: true, order: 20)]
    public string $status = '';

    #[Field(sortable: true, format: 'date', order: 30)]
    public \DateTimeImmutable $createdAt;
}
```

Attribute lanes — no overlap
----------------------------

[](#attribute-lanes--no-overlap)

```
#[With(description: 'Execution status', enum: ['pending', 'done', 'failed'])]  // LLM schema
#[ApiProperty('Current execution status')]                                       // OpenAPI
#[Field(filterable: true, widget: Widget::Select, facet: true)]                 // display/search
public AiTaskStatus $status;
```

---

Installation
------------

[](#installation)

```
composer require survos/field-bundle
```

---

Attributes
----------

[](#attributes)

The bundle provides five PHP attributes covering properties, entities, and controllers.

### `#[Field]` — property / method level

[](#field--property--method-level)

```
use Survos\FieldBundle\Attribute\Field;
use Survos\FieldBundle\Enum\Widget;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class Field
{
    public function __construct(
        ?string $transKey   = null,   // translation key override (looked up in 'fields' domain)
        bool    $searchable = false,  // include in full-text search
        bool    $sortable   = false,  // allow ordering
        bool    $filterable = false,  // expose a filter control
        ?Widget $widget     = null,   // filter UI widget; inferred from PHP type when null
        bool    $facet      = false,  // include in facet panel (sidebar, searchList, refinements)
        bool    $visible    = true,   // show by default (false = hidden but toggleable)
        int     $order      = 100,    // column display position (lower = further left)
        ?string $width      = null,   // CSS width hint, e.g. '8rem'
        ?string $format     = null,   // display format: 'date', 'datetime', 'currency', etc.
    ) {}
}
```

**Widget inference** — when `widget` is null, `FieldDescriptor::resolvedWidget()` infers from PHP type:

PHP typeInferred widget`bool``Widget::Boolean``int`, `float``Widget::Range``\DateTimeInterface``Widget::Date`backed enum`Widget::Select``string``Widget::Text`Widget is only inferred when `filterable: true`. Non-filterable fields return `null`.

**Browsability** — `Widget::Select` and `Widget::Boolean` are "browsable" (render as selectable lists in ColumnControl / SearchBuilder / facet panels). `Widget::Range`, `Widget::Date`, and `Widget::Text` are filterable but not browsable — they render as input controls.

### `#[EntityMeta]` — class level

[](#entitymeta--class-level)

Class-level metadata for admin UI, dashboard cards, and menu auto-registration.

```
use Survos\FieldBundle\Attribute\EntityMeta;

#[EntityMeta(
    icon: 'mdi:building',
    group: 'Content',
    order: 10,
    label: 'Tenant',
    description: 'A workspace that can own projects and members.',
    adminBrowsable: true,
)]
class Tenant { ... }
```

Parameters:

ParameterTypeDefaultDescription`icon`stringnullUX icon name, e.g. `'mdi:building'`, `'tabler:user'``iconClass`stringnullCSS class for the icon, e.g. `'text-primary'``order`int100Position within the group (lower = first)`group`string`'General'`Section/submenu header in admin nav`label`stringnullHuman-readable label; defaults to short class name`description`stringnullOne-line description for dashboard cards`adminBrowsable`booltrueInclude in admin navbar and dashboardDiscovered at compile time by `EntityMetaPass`, which scans all Doctrine entity directories.

**Twig globals** — every `#[EntityMeta]` entity is exposed as a Twig global keyed by `APP_ENTITY_{SHORTNAME}` (upper-snake of short class name). Use this to avoid class strings in templates:

```
{# Instead of constant('App\\Entity\\Song') #}

{# Iterate all registered entities #}
{% for descriptor in ENTITY_META.all %}
    {{ descriptor.label }}: {{ descriptor.class }}
{% endfor %}
```

### `#[RouteIdentity]` — class level

[](#routeidentity--class-level)

Declares how an entity identifies itself in URLs. This is fundamental to Survos navigation: entities generate their own route parameters with `getRp()`, controllers resolve typed entity arguments from those same parameters, and templates link with `path('route_name', entity.rp)`.

This replaces the legacy `UNIQUE_PARAMETERS` const pattern from `survos/core-bundle` and avoids repeating `#[MapEntity]` mappings on every controller method.

```
use Survos\FieldBundle\Attribute\RouteIdentity;

// Simple: single field
#[RouteIdentity(field: 'code')]
class Tenant implements RouteParametersInterface
{
    use RouteIdentityTrait;
    #[ORM\Column] public string $code;
}

// Nested: child entity walks the parent chain automatically
#[RouteIdentity(field: 'code', parents: ['tenant'], key: 'projectCode')]
class Project implements RouteParametersInterface
{
    use RouteIdentityTrait;
    #[ORM\ManyToOne] public Tenant $tenant;
    #[ORM\Column]    public string $code;
}

// $project->getRp() → ['tenantId' => 'acme', 'projectCode' => 'photo-archive']
// No manual merge — the parent chain resolves automatically.
```

Parameters:

ParameterTypeDescription`field`stringProperty name or getter to read (e.g. `'code'` → `$entity->code` or `$entity->getCode()`)`parents`string\[\]Property names of associations to walk for parent route params`key`stringOverride the URL parameter key (defaults to `{lcfirst(ShortName)}Id`)**`RouteIdentityTrait`** implements `getRp()`, `getUniqueIdentifiers()`, and `erp()` for the entity. Pair with `implements RouteParametersInterface` from `survos/core-bundle`.

#### Navigation Contract

[](#navigation-contract)

For every navigable Doctrine entity, use this pattern:

```
use Survos\CoreBundle\Entity\RouteParametersInterface;
use Survos\FieldBundle\Attribute\RouteIdentity;
use Survos\FieldBundle\Entity\RouteIdentityTrait;

#[RouteIdentity(field: 'id')]
class Image implements RouteParametersInterface
{
    use RouteIdentityTrait;

    #[ORM\Id]
    #[ORM\Column(length: 26)]
    public string $id;
}
```

Then name the route parameter after the generated key. The default key is `{lcfirst(shortClassName)}Id`, so `Image` becomes `imageId`, `Item` becomes `itemId`, and `GalleryImage` becomes `galleryImageId`.

```
#[Route('/image/{imageId}')]
final class ImageController extends AbstractController
{
    #[Route('/show', name: 'image_show')]
    public function show(Image $image): array
    {
        return ['image' => $image];
    }
}
```

Templates should not rebuild route parameters manually:

```
{{ image }}
```

For a custom route key, declare it on the entity and use that key in the route:

```
#[RouteIdentity(field: 'code', key: 'intakeCode', parents: ['tenant'])]
class Intake implements RouteParametersInterface
{
    use RouteIdentityTrait;
}

#[Route('/{tenantId}/i/{intakeCode}')]
final class IntakeController extends AbstractController
{
    #[Route('/show', name: 'intake_show')]
    public function show(Intake $intake): array
    {
        return ['intake' => $intake];
    }
}
```

The entity is the single source of truth for route identity:

- `entity.rp` generates URL parameters.
- `RouteIdentityValueResolver` resolves controller arguments.
- Menus, labels, redirects, and templates all use the same contract.
- If the route parameter name does not match the identity key, typed entity resolution will not run.

Migration from old pattern:

```
// Before (core-bundle)
class Owner implements RouteParametersInterface
{
    use RouteParametersTrait;
    public const array UNIQUE_PARAMETERS = ['ownerId' => 'code'];
}

// After (field-bundle)
#[RouteIdentity(field: 'code')]
class Owner implements RouteParametersInterface
{
    use RouteIdentityTrait;
}
```

### `#[RouteMeta]` — method level

[](#routemeta--method-level)

Metadata for individual controller actions. Powers sitemap generation, AI introspection, breadcrumbs, nav, and OpenAPI projection.

```
use Survos\FieldBundle\Attribute\RouteMeta;
use Survos\FieldBundle\Enum\Audience;
use Survos\FieldBundle\Enum\Purpose;

#[Route('/tenant/{tenantId}', name: 'tenant_show')]
#[RouteMeta(
    description: 'Public overview page for a tenant.',
    entity: Tenant::class,
    purpose: Purpose::Show,
    audience: Audience::Public,
    sitemap: true,
    changefreq: 'weekly',
)]
public function show(Tenant $tenant): array { ... }
```

Key parameters:

ParameterTypeDescription`description`string**Required.** Dev-facing English prose. Used for AI, OpenAPI, dashboards.`entity`class-stringPrimary entity this route operates on`purpose`PurposeWhat the route does (`List`, `Show`, `New`, `Edit`, `Delete`, `Export`, `Custom`)`audience`AudienceWho it's for (`Public`, `Authenticated`, `Admin`, `Api`, `Internal`)`sitemap`boolInclude in sitemap.xml (defaults to true for Public routes)`changefreq`stringsitemap ``: `always`|`daily`|`weekly`|`monthly`|…`priority`floatsitemap ``: 0.0–1.0`tags`string\[\]Free-form labels: `['admin', 'export', 'beta', …]``parents`string\[\]Route names for breadcrumb parents### `#[ControllerMeta]` — class level

[](#controllermeta--class-level)

Class-level defaults for `#[RouteMeta]`. Avoids repeating `entity:`, `audience:`, and `tags:` on every action.

```
use Survos\FieldBundle\Attribute\ControllerMeta;

#[Route('/tenant/{tenantId}')]
#[ControllerMeta(entity: Tenant::class, audience: Audience::Authenticated)]
final class TenantController extends AbstractController
{
    #[Route('', name: 'tenant_show')]
    #[RouteMeta(description: 'Tenant detail page', purpose: Purpose::Show, audience: Audience::Public)]
    public function show(Tenant $tenant): array { ... }

    // Inherits entity: Tenant::class, audience: Audience::Authenticated from ControllerMeta
    #[Route('/edit', name: 'tenant_edit')]
    #[RouteMeta(description: 'Edit tenant settings', purpose: Purpose::Edit)]
    public function edit(Tenant $tenant, Request $request): array|RedirectResponse { ... }
}
```

`RouteMetaPass` merges class-level `ControllerMeta` defaults under each method's `#[RouteMeta]`. The method always wins for any field it sets explicitly; `ControllerMeta` fills the gaps.

---

`FieldReader` — reading descriptors at runtime
----------------------------------------------

[](#fieldreader--reading-descriptors-at-runtime)

`FieldReader` is the main service for consuming `#[Field]` metadata programmatically. Inject it anywhere:

```
use Survos\FieldBundle\Service\FieldReader;
use Survos\FieldBundle\Model\FieldDescriptor;

class MyService
{
    public function __construct(private readonly FieldReader $fieldReader) {}

    public function buildSearchConfig(string $class): array
    {
        $descriptors = $this->fieldReader->getDescriptors($class);

        return [
            'searchable' => array_map(fn (FieldDescriptor $d) => $d->name,
                array_filter($descriptors, fn ($d) => $d->searchable)),
            'sortable'   => array_map(fn (FieldDescriptor $d) => $d->name,
                array_filter($descriptors, fn ($d) => $d->sortable)),
        ];
    }

    // Get a single property descriptor
    public function getLabel(string $class, string $property): string
    {
        $d = $this->fieldReader->getDescriptor($class, $property);
        return $d?->getFallbackLabel() ?? $property;
    }
}
```

### `FieldDescriptor` properties

[](#fielddescriptor-properties)

PropertyTypeSource`name`stringProperty/method name`type`stringPHP type (e.g. `'string'`, `'int'`, `'App\Enum\Status'`)`transKey`?string`#[Field(transKey:)]` or null`description`?string`#[With]`, `#[ApiProperty]`, or null`example`mixed`#[With]`, `#[ApiProperty]`, or null`searchable`bool`#[Field]` or `#[ApiFilter(SearchFilter)]``sortable`bool`#[Field]` or `#[ApiFilter(OrderFilter)]``filterable`bool`#[Field]` or `#[ApiFilter]``widget`?Widget`#[Field(widget:)]` or inferred`facet`bool`#[Field(facet:)]``visible`bool`#[Field(visible:)]``order`int`#[Field(order:)]``width`?string`#[Field(width:)]``format`?string`#[Field(format:)]``enum`scalar\[\]Backed enum cases, or `#[With(enum:)]``minimum`int|float`#[With(minimum:)]` or `#[Range]` constraint`maximum`int|float`#[With(maximum:)]` or `#[Range]` constraint`maxLength`?int`#[Length(max:)]` constraint`pattern`?string`#[Regex(pattern:)]` constraint`required`bool`#[NotBlank]` constraint`isUrl`bool`#[Url]` constraint`isEmail`bool`#[Email]` constraintKey methods:

```
$d->getTranslationKey()     // transKey ?? name
$d->getTranslationDomain()  // 'fields' (always)
$d->getFallbackLabel()      // TitleCase of name, e.g. 'accountType' → 'Account Type'
$d->resolvedWidget()        // widget ?? inferred from type (null when not filterable)
$d->inputType()             // HTML input type: 'email'|'url'|'number'|'datetime-local'|'text'
```

### Progressive enhancement sources

[](#progressive-enhancement-sources)

`FieldReader` enriches descriptors when optional packages are present:

SourcePackageWhat it adds`#[Field]`*(this bundle)*All display/search settingsSymfony validation`symfony/validator``required`, `isUrl`, `isEmail`, `minimum`, `maximum`, `maxLength`, `pattern``#[With]``symfony/ai-platform``description`, `example`, `enum`, `minimum`, `maximum``#[ApiProperty]``api-platform/core``description`, `example``#[ApiFilter]` on class`api-platform/core``searchable`, `sortable`, `filterable` (fallback when no `#[Field]`)`#[MeiliIndex]` on class`survos/meili-bundle``searchable`, `sortable`, `filterable` (synthesized fallback)PHP reflection*(always)*`type`, backed enum cases**Fallback synthesis** — properties with no `#[Field]` but referenced in `#[ApiFilter]` or `#[MeiliIndex]` get a synthesized descriptor so the grid still shows them correctly. Add `#[Field]` to take explicit control.

---

Widget mapping across consumers
-------------------------------

[](#widget-mapping-across-consumers)

`Widget`ColumnControl (api-grid)Meilisearch (meili-bundle)UX-Search`Text``search` inputsearchableSearchBox`Select``searchList` dropdownRefinementListRefinementList`Range`Min/Max number inputsRangeSliderRangeSlider`Date`*(future)*NumericMenuDateRangePicker`Boolean``searchList` dropdownToggleToggleRefinement---

Zero required dependencies
--------------------------

[](#zero-required-dependencies)

`#[Field]` and `Widget` have no external dependencies — just PHP 8.4. `FieldReader` enhances output progressively based on what packages are installed.

---

Consumers
---------

[](#consumers)

BundleWhat it uses`survos/api-grid-bundle``FieldReader::getDescriptors()` → column sortable/searchable/browsable/width/widget`survos/grid-bundle`DataTables column config`survos/meili-bundle`Meilisearch searchable/filterable/sortable/facet index settings`survos/inspection-bundle`Unified `FieldDescriptor` DTO for Twig templates and admin tooling---

Further reading
---------------

[](#further-reading)

- [`docs/CONTROLLERS.md`](docs/CONTROLLERS.md) — Survos controller naming convention: `XxxController` (entity) vs `XxxListController` (collection). Covers `#[RouteMeta]`, `#[ControllerMeta]`, `#[RouteIdentity]`, entity injection, and testing patterns.

###  Health Score

51

—

FairBetter than 95% of packages

Maintenance99

Actively maintained with recent releases

Popularity16

Limited adoption so far

Community17

Small or concentrated contributor base

Maturity63

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

42

Last Release

11d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/21b39551f92ed4143772c622f9e571589c5a72c96ab3c53fe67489ce0d83e806?d=identicon)[tacman1123](/maintainers/tacman1123)

---

Top Contributors

[![tacman](https://avatars.githubusercontent.com/u/619585?v=4)](https://github.com/tacman "tacman (19 commits)")

---

Tags

searchsymfonymetadatadatatablesgridfieldapi-platformmeili

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/survos-field-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/survos-field-bundle/health.svg)](https://phpackages.com/packages/survos-field-bundle)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
