survos/field-bundle
最新稳定版本:2.2.2
Composer 安装命令:
composer require survos/field-bundle
包简介
Universal field/property metadata attributes for search, grid, and AI — #[Field], Widget enum, and FieldReader.
README 文档
README
Universal field/property metadata for Symfony — declare once, consume everywhere.
The problem
Property metadata is scattered across attributes with overlapping concerns:
| Attribute | Owner | Covers |
|---|---|---|
#[ApiProperty] |
api-platform | OpenAPI description, example |
#[With] |
symfony/ai | JSON Schema constraints for LLMs |
#[ORM\Column] |
doctrine | Storage type |
#[ApiFilter] |
api-platform | Server-side filter declaration |
None of them answer: how should this property be displayed and filtered in a grid, search panel, or UX-search widget?
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
#[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
composer require survos/field-bundle
Attributes
The bundle provides five PHP attributes covering properties, entities, and controllers.
#[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 type | Inferred 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
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:
| Parameter | Type | Default | Description |
|---|---|---|---|
icon |
string | null | UX icon name, e.g. 'mdi:building', 'tabler:user' |
iconClass |
string | null | CSS class for the icon, e.g. 'text-primary' |
order |
int | 100 | Position within the group (lower = first) |
group |
string | 'General' |
Section/submenu header in admin nav |
label |
string | null | Human-readable label; defaults to short class name |
description |
string | null | One-line description for dashboard cards |
adminBrowsable |
bool | true | Include in admin navbar and dashboard |
Discovered 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') #} <twig:api_grid :class="APP_ENTITY_SONG" ... /> {# Iterate all registered entities #} {% for descriptor in ENTITY_META.all %} {{ descriptor.label }}: {{ descriptor.class }} {% endfor %}
#[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:
| Parameter | Type | Description |
|---|---|---|
field |
string | Property 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 |
string | Override 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
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:
<a href="{{ path('image_show', image.rp) }}">{{ image }}</a>
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.rpgenerates URL parameters.RouteIdentityValueResolverresolves 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
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:
| Parameter | Type | Description |
|---|---|---|
description |
string | Required. Dev-facing English prose. Used for AI, OpenAPI, dashboards. |
entity |
class-string | Primary entity this route operates on |
purpose |
Purpose | What the route does (List, Show, New, Edit, Delete, Export, Custom) |
audience |
Audience | Who it's for (Public, Authenticated, Admin, Api, Internal) |
sitemap |
bool | Include in sitemap.xml (defaults to true for Public routes) |
changefreq |
string | sitemap <changefreq>: always|daily|weekly|monthly|… |
priority |
float | sitemap <priority>: 0.0–1.0 |
tags |
string[] | Free-form labels: ['admin', 'export', 'beta', …] |
parents |
string[] | Route names for breadcrumb parents |
#[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 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
| Property | Type | Source |
|---|---|---|
name |
string | Property/method name |
type |
string | PHP 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] constraint |
Key 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
FieldReader enriches descriptors when optional packages are present:
| Source | Package | What it adds |
|---|---|---|
#[Field] |
(this bundle) | All display/search settings |
| Symfony 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 |
ColumnControl (api-grid) | Meilisearch (meili-bundle) | UX-Search |
|---|---|---|---|
Text |
search input |
searchable | SearchBox |
Select |
searchList dropdown |
RefinementList | RefinementList |
Range |
Min/Max number inputs | RangeSlider | RangeSlider |
Date |
(future) | NumericMenu | DateRangePicker |
Boolean |
searchList dropdown |
Toggle | ToggleRefinement |
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
| Bundle | What 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
docs/CONTROLLERS.md— Survos controller naming convention:XxxController(entity) vsXxxListController(collection). Covers#[RouteMeta],#[ControllerMeta],#[RouteIdentity], entity injection, and testing patterns.
统计信息
- 总下载量: 91
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 3
- 依赖项目数: 2
- 推荐数: 1
其他信息
- 授权协议: MIT
- 更新时间: 2026-04-26