smartlabs / sonata-inline-relation-bundle
Inline editing of complex associations (OneToMany/ManyToMany with pivot entities) in Sonata Admin list views
Package info
github.com/smartlabsAT/sonata-inline-relation-bundle
Type:symfony-bundle
pkg:composer/smartlabs/sonata-inline-relation-bundle
Requires
- php: ^8.2
- doctrine/orm: ^3.0
- sonata-project/admin-bundle: ^4.0
- symfony/framework-bundle: ^7.0
- symfony/property-access: ^7.0
- symfony/security-csrf: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0 || ^12.0
- dev-main / 1.0.x-dev
- 1.0.19
- dev-feature/45-example-composer-cleanup
- dev-feature/43-example-app
- dev-feature/41-readme-polish
- dev-feature/39-audit-followup
- dev-feature/37-relation-constraints-pivot-editability
- dev-feature/35-optional-confirm-remove
- dev-feature/production-audit-fixes
- dev-feature/design-improvements
- dev-feature/label-property
- dev-feature/readme-documentation
- dev-feature/event-dispatcher
- dev-feature/sonata-confirm-dialog
- dev-feature/custom-search-query-builder
- dev-feature/bundle-semantic-configuration
- dev-feature/configurable-max-search-results
- dev-feature/additional-field-types
- dev-feature/generic-unique-boolean-sorting
- dev-feature/flexible-field-mapping
- dev-feature/i18n-translations
- dev-feature/github-actions-ci
- dev-feature/9-testing-documentation
- dev-feature/8-security-error-handling
- dev-feature/7-drag-drop-sorting
- dev-feature/6-add-remove-relations
- dev-feature/5-pivot-field-editing
- dev-feature/4-modal-and-load-api
- dev-feature/3-readonly-template
- dev-feature/2-bundle-skeleton
This package is auto-updated.
Last update: 2026-03-12 13:36:39 UTC
README
Overview
SonataInlineRelationBundle adds inline editing of complex associations (OneToMany / ManyToMany with pivot entities) directly in Sonata Admin list views. Instead of navigating to a separate edit page, administrators click a pencil icon to open a popover panel anchored to the trigger cell where they can edit pivot fields, reorder entries via drag-and-drop, add new relations with a searchable dropdown, and remove existing ones -- all persisted instantly via AJAX.
| Category relations with drag-and-drop, radio & checkbox | Tag relations with number, text & select fields |
![]() |
![]() |
Try it yourself: Run the example app with a single docker compose up -d — no existing project required.
Features
- Display pivot-entity collections as a compact column in any Sonata Admin list view
- Edit pivot fields inline: checkbox, radio, text, number, and select controls
- Radio fields enforce exclusivity automatically (only one entry can be
trueat a time) - Drag-and-drop reordering with automatic position persistence
- Add new relations through a Select2 search dropdown (with configurable filters)
- Remove existing relations with a Sonata-style confirmation dialog
- Configurable default values for new pivot entries
- Nullable
relation_fieldfor direct entity mapping (without a related entity) - Custom search query builder support for complex search scenarios
- Bundle-level default configuration with per-field overrides
- Symfony Event Dispatcher integration for all write operations
- Full internationalization with EN and DE translations out of the box
- CSRF protection, IDOR prevention, XHR-only enforcement, and Sonata Admin access checks
- Real-time list cell updates without page reload
- No external JavaScript dependencies beyond jQuery and Select2 (both included in Sonata Admin)
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Symfony | ^7.0 |
| Sonata Admin Bundle | ^4.0 |
| Doctrine ORM | ^3.0 |
The bundle also requires symfony/property-access and symfony/security-csrf, which are typically already installed in any Symfony + Sonata Admin project.
Installation
1. Install the package
composer require smartlabs/sonata-inline-relation-bundle
2. Register the bundle
If Symfony Flex did not register it automatically, add it to config/bundles.php:
return [ // ... Smartlabs\SonataInlineRelationBundle\SonataInlineRelationBundle::class => ['all' => true], ];
3. Import routes
Create config/routes/sonata_inline_relation.yaml:
sonata_inline_relation: resource: '@SonataInlineRelationBundle/Resources/config/routing.php'
Or as a PHP configurator (config/routes/sonata_inline_relation.php):
<?php declare(strict_types=1); use Smartlabs\SonataInlineRelationBundle\SonataInlineRelationBundle; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; return static function (RoutingConfigurator $routes): void { $bundleDir = dirname((new \ReflectionClass(SonataInlineRelationBundle::class))->getFileName()); $routes->import($bundleDir . '/Resources/config/routing.php'); };
4. Install assets
php bin/console assets:install
The bundle automatically registers its CSS and JS with Sonata Admin via PrependExtensionInterface -- no manual asset configuration is needed.
Note: If you override the standard Sonata Admin layout template (
@SonataAdmin/standard_layout.html.twig) and do not call{{ parent() }}in thestylesheets/javascriptsblocks, add the assets manually:<link rel="stylesheet" href="{{ asset('bundles/sonatainlinerelation/css/inline-relation.css') }}"> <script src="{{ asset('bundles/sonatainlinerelation/js/inline-relation.js') }}"></script>
Symfony Flex: There is no Flex recipe for this bundle yet. Steps 2 and 3 must be done manually.
Quick Start
Step 1: Define a pivot entity
The bundle operates on pivot entities -- Doctrine entities that represent a join table row with additional fields. Each pivot entity must reference both the "owner" and the "related" entity.
<?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[ORM\Table(name: 'product_category')] class ProductCategory { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'productCategories')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private ?Product $product = null; #[ORM\ManyToOne(targetEntity: Category::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private ?Category $category = null; #[ORM\Column(type: 'boolean')] private bool $main = false; #[ORM\Column(type: 'boolean')] private bool $enabled = true; #[ORM\Column(type: 'integer', options: ['default' => 0])] private int $position = 0; // Getters and setters... public function getId(): ?int { return $this->id; } public function getProduct(): ?Product { return $this->product; } public function setProduct(?Product $product): void { $this->product = $product; } public function getCategory(): ?Category { return $this->category; } public function setCategory(?Category $category): void { $this->category = $category; } public function isMain(): bool { return $this->main; } public function setMain(bool $main): void { $this->main = $main; } public function isEnabled(): bool { return $this->enabled; } public function setEnabled(bool $enabled): void { $this->enabled = $enabled; } public function getPosition(): int { return $this->position; } public function setPosition(int $position): void { $this->position = $position; } }
The owner entity must expose the pivot collection:
#[ORM\OneToMany(targetEntity: ProductCategory::class, mappedBy: 'product', cascade: ['persist', 'remove'])] private Collection $productCategories;
The related entity (e.g. Category) must implement __toString() -- the return value is used as the display label.
Step 2: Configure the admin list field
In your Sonata Admin class, add the field to configureListFields with the type inline_relation:
use Sonata\AdminBundle\Datagrid\ListMapper; protected function configureListFields(ListMapper $list): void { $list->add('productCategories', 'inline_relation', [ 'template' => '@SonataInlineRelation/list_inline_relation.html.twig', 'pivot_entity' => \App\Entity\ProductCategory::class, 'relation_field' => 'category', 'owner_field' => 'product', 'sortable' => true, 'sort_field' => 'position', 'pivot_fields' => [ 'main' => ['type' => 'radio', 'label' => 'Main'], 'enabled' => ['type' => 'checkbox', 'label' => 'Active', 'default' => true], ], 'add_allowed' => true, 'remove_allowed' => true, 'search_property' => 'name', 'search_filter' => ['context' => 'product_category'], ]); }
How it works
The field name (productCategories) is the property path on the owner entity that holds the pivot collection. When the list view renders, the bundle reads this collection, resolves each related entity through relation_field, and displays the labels. Clicking the pencil icon opens a popover panel with the full interactive UI.
All write operations (update, reorder, add, remove) go through dedicated AJAX endpoints. The controller resolves the admin and object from the request, verifies edit access, reads the field configuration from the admin's list field description, and delegates to the InlineRelationHandler service.
Configuration Reference
Bundle-level defaults
Configure bundle-wide defaults in config/packages/sonata_inline_relation.yaml:
sonata_inline_relation: defaults: max_search_results: 20 # Default maximum search results (min: 1) sortable: false # Enable drag-and-drop by default add_allowed: false # Allow adding relations by default remove_allowed: false # Allow removing relations by default confirm_remove: true # Show confirmation dialog before removing editable_pivot_fields: true # Allow editing pivot fields (false = read-only) min_relations: 0 # Minimum relations required (0 = no minimum) max_relations: 0 # Maximum relations allowed (0 = no maximum)
Field-level options in configureListFields() override these bundle-level defaults.
Field-level options
These options are passed to $list->add() in your admin class:
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
template |
string |
-- | Yes | Must be @SonataInlineRelation/list_inline_relation.html.twig |
pivot_entity |
string (FQCN) |
-- | Yes | Full class name of the pivot entity |
relation_field |
string|null |
-- | Yes | Property on pivot pointing to the related entity. Set to null for direct entity mapping (see Nullable relation_field) |
owner_field |
string |
-- | Yes | Property on pivot pointing to the owning entity |
sortable |
bool |
false |
No | Enable drag-and-drop reordering. Requires sort_field |
sort_field |
string|null |
null |
No | Property on pivot used for ordering (e.g. 'position') |
pivot_fields |
array |
[] |
No | Map of pivot field names to control configuration (see below) |
add_allowed |
bool |
false |
No | Show Select2 dropdown for adding new relations |
remove_allowed |
bool |
false |
No | Show remove button on each entry |
confirm_remove |
bool |
true |
No | Show confirmation dialog before removing a relation. Set to false for immediate removal without confirmation |
editable_pivot_fields |
bool |
true |
No | Whether pivot fields are editable. Set to false to render all pivot controls as disabled (read-only display) |
min_relations |
int |
0 |
No | Minimum number of relations required. When below this value, the badge in the popover header turns red as a visual indicator. 0 means no minimum |
max_relations |
int |
0 |
No | Maximum number of relations allowed. When reached, the add dropdown is hidden. 0 means no maximum |
search_property |
string |
-- | Cond. | Property on related entity for LIKE search. Required when add_allowed is true (unless search_query_builder is provided) |
search_filter |
array |
[] |
No | Key-value pairs added as WHERE conditions when searching (e.g. ['context' => 'product_category']) |
search_query_builder |
callable |
null |
No | Custom QueryBuilder for search (see Custom search query builder) |
max_search_results |
int |
20 |
No | Maximum number of search results returned |
label_property |
string|null |
null |
No | Property/method on the related entity to use for display labels instead of __toString() (see Custom label property) |
Pivot field types
Each key in pivot_fields is the property name on the pivot entity. The value is a configuration array:
checkbox
Toggles independently per entry.
'enabled' => ['type' => 'checkbox', 'label' => 'Active', 'default' => true]
| Sub-option | Type | Required | Description |
|---|---|---|---|
type |
'checkbox' |
Yes | |
label |
string |
No | Display label and tooltip |
default |
bool |
No | Default value for new entries |
radio
Enforces exclusivity across the collection. Setting one entry to true automatically sets all others to false. When no sort_field is configured, entries with the radio field set to true are sorted first.
'main' => ['type' => 'radio', 'label' => 'Main', 'default' => false]
| Sub-option | Type | Required | Description |
|---|---|---|---|
type |
'radio' |
Yes | |
label |
string |
No | Display label and tooltip |
default |
bool |
No | Default value for new entries |
text
Free-form text input. Saves on blur or Enter key.
'note' => ['type' => 'text', 'label' => 'Note', 'default' => '']
| Sub-option | Type | Required | Description |
|---|---|---|---|
type |
'text' |
Yes | |
label |
string |
No | Display label and tooltip |
default |
string |
No | Default value for new entries |
number
Numeric input with optional constraints. Saves on blur or Enter key.
'quantity' => ['type' => 'number', 'label' => 'Qty', 'default' => 1, 'min' => 0, 'max' => 999, 'step' => 1]
| Sub-option | Type | Required | Description |
|---|---|---|---|
type |
'number' |
Yes | |
label |
string |
No | Display label and tooltip |
default |
int|float |
No | Default value for new entries |
min |
int|float |
No | Minimum allowed value |
max |
int|float |
No | Maximum allowed value |
step |
int|float |
No | Step increment |
select
Dropdown with predefined choices. Saves on change.
'role' => [ 'type' => 'select', 'label' => 'Role', 'default' => 'viewer', 'choices' => [ 'admin' => 'Administrator', 'editor' => 'Editor', 'viewer' => 'Viewer', ], ]
| Sub-option | Type | Required | Description |
|---|---|---|---|
type |
'select' |
Yes | |
label |
string |
No | Display label and tooltip |
default |
string |
No | Default value for new entries |
choices |
array |
Yes | Key-value map (value => label) |
Full configuration example
$list->add('teamMembers', 'inline_relation', [ 'template' => '@SonataInlineRelation/list_inline_relation.html.twig', 'pivot_entity' => \App\Entity\ProjectMember::class, 'relation_field' => 'user', 'owner_field' => 'project', 'sortable' => true, 'sort_field' => 'position', 'pivot_fields' => [ 'lead' => ['type' => 'radio', 'label' => 'Lead', 'default' => false], 'active' => ['type' => 'checkbox', 'label' => 'Active', 'default' => true], 'hours' => ['type' => 'number', 'label' => 'Hours', 'default' => 0, 'min' => 0, 'step' => 0.5], 'note' => ['type' => 'text', 'label' => 'Note'], 'role' => ['type' => 'select', 'label' => 'Role', 'default' => 'member', 'choices' => [ 'admin' => 'Admin', 'member' => 'Member', 'guest' => 'Guest', ]], ], 'add_allowed' => true, 'remove_allowed' => true, 'search_property' => 'email', 'max_search_results' => 50, ]);
Advanced Usage
Nullable relation_field
When relation_field is set to null, the bundle treats the pivot entity itself as the display entity. The pivot's __toString() method is used for the label instead of resolving a related entity. This is useful for direct entity collections without an intermediate join.
$list->add('tags', 'inline_relation', [ 'template' => '@SonataInlineRelation/list_inline_relation.html.twig', 'pivot_entity' => \App\Entity\ProductTag::class, 'relation_field' => null, 'owner_field' => 'product', 'remove_allowed' => true, ]);
Note: When
relation_fieldisnull, adding new relations (add_allowed) is not supported -- the bundle would not know which entity to link.
Custom label property
By default, the bundle uses __toString() on the related entity (or the pivot entity when relation_field is null) to generate display labels. This works for simple cases, but when entities need different label representations in different contexts, you can configure a custom property or method via label_property.
This is especially useful for hierarchical entities where the same name appears under multiple parents (e.g. a Category named "Gesicht" that exists under both "Koerperpflege" and "Kosmetik").
$list->add('productCategories', 'inline_relation', [ // ... 'label_property' => 'hierarchicalName', ]);
The value is read via Symfony PropertyAccessor, so it can be a getter method (getHierarchicalName()), a public property, or any valid property path.
Example entity method:
// src/Entity/Classification/Category.php class Category extends BaseCategory { public function getHierarchicalName(): string { $parts = []; $category = $this; while (null !== $category) { // Skip the root category (the one without a parent) if (null !== $category->getParent()) { $parts[] = (string) $category; } $category = $category->getParent(); } return implode(' > ', array_reverse($parts)); } }
This produces labels like "Koerperpflege > Gesicht" in both the list cell and the Select2 search dropdown, while __toString() continues to return just "Gesicht" everywhere else.
When label_property is configured, search results are automatically sorted alphabetically by the computed label text. This ensures hierarchical labels display in a natural order (e.g. "Gesicht" before "Gesicht > Serum" before "Sonnenschutz > Gesicht").
When label_property is not configured (default), the behavior is unchanged -- __toString() is used and the query's original sort order is preserved.
Custom search query builder
For complex search scenarios (e.g. joins, computed fields, access control), provide a custom callable via the search_query_builder option. The callable receives the EntityManager, the related entity class, the search term, and any configured search_filter, and must return a QueryBuilder:
$list->add('productCategories', 'inline_relation', [ // ... other options 'add_allowed' => true, 'search_query_builder' => function ( \Doctrine\ORM\EntityManagerInterface $em, string $class, string $term, array $filters, ): \Doctrine\ORM\QueryBuilder { $qb = $em->getRepository($class)->createQueryBuilder('e') ->join('e.translations', 't') ->where('LOWER(t.name) LIKE :term') ->setParameter('term', '%' . mb_strtolower($term) . '%'); if (isset($filters['context'])) { $qb->andWhere('e.context = :context') ->setParameter('context', $filters['context']); } return $qb; }, ]);
When search_query_builder is provided, search_property is not required. The bundle still applies excluded IDs (already-assigned entities) and max_search_results on top of your custom query.
Event Dispatcher integration
The bundle dispatches Symfony events for all write operations. You can listen to these events for cache invalidation, audit logging, notifications, or any other side effects.
Available events
| Event Class | Dispatched When |
|---|---|
InlineRelationUpdateEvent |
After a pivot field value is changed |
InlineRelationAddEvent |
After a new relation is added |
InlineRelationRemoveEvent |
After a relation is removed |
InlineRelationReorderEvent |
After entries are reordered |
All events implement InlineRelationEventInterface, which provides:
getOwner(): object-- the owning entitygetOptions(): array-- the field configuration array
Event-specific data
InlineRelationUpdateEvent adds:
getPivotEntity(): object-- the updated pivot entitygetField(): string-- the pivot field name that was changedgetValue(): mixed-- the new value
InlineRelationAddEvent adds:
getPivotEntity(): object-- the newly created pivot entity
InlineRelationRemoveEvent adds:
getPivotEntity(): object-- the removed pivot entity (still accessible in the listener)
InlineRelationReorderEvent adds:
getPivotEntities(): array-- list of reordered pivot entities (in their new order)
Example listener
<?php namespace App\EventListener; use Smartlabs\SonataInlineRelationBundle\Event\InlineRelationAddEvent; use Smartlabs\SonataInlineRelationBundle\Event\InlineRelationRemoveEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; class ProductCategoryListener { #[AsEventListener(event: InlineRelationAddEvent::class)] public function onRelationAdded(InlineRelationAddEvent $event): void { $product = $event->getOwner(); $pivot = $event->getPivotEntity(); // e.g. invalidate cache, send notification, log audit trail } #[AsEventListener(event: InlineRelationRemoveEvent::class)] public function onRelationRemoved(InlineRelationRemoveEvent $event): void { $product = $event->getOwner(); // e.g. recalculate aggregates } }
Internationalization (i18n)
The bundle ships with English (default) and German translations. All UI strings in the popover are translatable via the SonataInlineRelationBundle translation domain.
Translation keys
| Key | EN | DE |
|---|---|---|
inline_relation.modal_title |
Edit relations | Zuordnungen bearbeiten |
inline_relation.empty_state |
No relations | Keine Zuordnungen |
inline_relation.error_loading |
Error loading | Fehler beim Laden |
inline_relation.error_connection |
Connection error | Verbindungsfehler |
inline_relation.error_saving |
Error saving | Fehler beim Speichern |
inline_relation.error_adding |
Error adding | Fehler beim Hinzufuegen |
inline_relation.error_removing |
Error removing | Fehler beim Entfernen |
inline_relation.error_reordering |
Error reordering | Fehler beim Sortieren |
inline_relation.add_placeholder |
Add relation... | Zuordnung hinzufuegen... |
inline_relation.confirm_remove |
Remove this relation? | Zuordnung entfernen? |
inline_relation.confirm_remove_yes |
Yes, remove | Ja, entfernen |
inline_relation.confirm_remove_cancel |
Cancel | Abbrechen |
inline_relation.drag_handle_title |
Sort | Sortieren |
inline_relation.remove_button_title |
Remove | Entfernen |
inline_relation.edit_button_title |
Edit | Bearbeiten |
inline_relation.min_relations_warning |
Minimum number of relations not met | Mindestanzahl an Zuordnungen nicht erreicht |
Adding custom translations
Create an XLIFF file for your locale (e.g. translations/SonataInlineRelationBundle.fr.xlf) in your application's translations/ directory. Symfony will automatically merge it with the bundle's built-in translations.
Architecture
Directory structure
src/
├── SonataInlineRelationBundle.php
├── Controller/
│ └── InlineRelationController.php # 6 AJAX endpoints
├── DependencyInjection/
│ ├── Configuration.php # Bundle semantic configuration
│ └── SonataInlineRelationExtension.php # Processes config, registers services
├── Event/
│ ├── InlineRelationEventInterface.php # Common event interface
│ ├── InlineRelationAddEvent.php
│ ├── InlineRelationRemoveEvent.php
│ ├── InlineRelationReorderEvent.php
│ └── InlineRelationUpdateEvent.php
├── Service/
│ ├── InlineRelationHandler.php # Core business logic (CRUD + reorder)
│ └── EntitySearchProvider.php # Related entity search (Select2)
├── Twig/
│ ├── InlineRelationExtension.php # Registers Twig functions
│ └── InlineRelationRuntime.php # Config + entries for templates
└── Resources/
├── config/
│ ├── services.php # Service definitions
│ └── routing.php # Route definitions
├── public/
│ ├── js/inline-relation.js # Frontend (popover, AJAX, drag-and-drop)
│ └── css/inline-relation.css # Styles (Bootstrap 3 / AdminLTE compatible)
├── views/
│ └── list_inline_relation.html.twig # List field template
└── translations/
├── SonataInlineRelationBundle.en.xlf
└── SonataInlineRelationBundle.de.xlf
Request flow
- List view renders -- Twig template calls
sonata_inline_relation_entries()andsonata_inline_relation_config()to display labels and embed the JSON configuration - User clicks pencil icon -- JavaScript reads the embedded config and opens a popover panel anchored to the trigger cell
- Popover loads entries -- GET request to
/admin/inline-relation/load - User interacts -- checkbox/radio/select changes trigger immediate POST to
/update; text/number fields save on blur or Enter; drag-and-drop triggers POST to/reorder; add triggers POST to/add; remove shows confirmation then POST to/remove - Controller processes -- validates CSRF, validates JSON payload, checks admin access, delegates to
InlineRelationHandler - Handler executes -- performs database operation, dispatches event, returns updated entries
- Frontend updates -- re-renders popover entries and updates the list cell in the background
API endpoints
| Route | Method | Path | Purpose |
|---|---|---|---|
sonata_inline_relation_load |
GET | /admin/inline-relation/load |
Load pivot entries |
sonata_inline_relation_update |
POST | /admin/inline-relation/update |
Update a pivot field |
sonata_inline_relation_reorder |
POST | /admin/inline-relation/reorder |
Reorder entries |
sonata_inline_relation_add |
POST | /admin/inline-relation/add |
Add a new relation |
sonata_inline_relation_remove |
POST | /admin/inline-relation/remove |
Remove a relation |
sonata_inline_relation_search |
GET | /admin/inline-relation/search |
Search for addable entities |
All endpoints require an X-Requested-With: XMLHttpRequest header. All POST endpoints require a valid CSRF token.
Security
The bundle implements multiple layers of security:
- CSRF protection -- All write operations require a valid CSRF token (token name:
inline_relation). Tokens are generated server-side and embedded in the template configuration. The config attribute (including CSRF token and API URLs) is only rendered for users witheditaccess. - Access control -- Every request calls
$admin->checkAccess('edit', $object), delegating authorization to Sonata Admin's built-in security layer.AccessDeniedExceptionreturns HTTP 403. - IDOR protection -- Before updating or removing a pivot entry, the handler verifies that the pivot entity belongs to the specified owner via
assertPivotBelongsToOwner(). - XHR validation -- All endpoints reject non-AJAX requests with a 400 status code.
- JSON payload validation -- All POST endpoints validate that the request body is valid JSON before processing.
- Field whitelist -- Only pivot fields that are explicitly configured in
pivot_fieldscan be modified. - DQL injection prevention -- Column names used in search queries are validated against a strict identifier pattern.
- Duplicate prevention -- Adding a relation that already exists throws an exception.
- Safe error responses -- Only known, safe exception types expose their message to the client. Unknown exceptions return a generic error message to prevent information disclosure.
- XSS prevention -- Frontend escapes all user-provided data via
escapeHtml(). Twig templates use auto-escaping. - XHR timeout -- All AJAX requests have a 30-second timeout to prevent permanent UI blocking on unresponsive servers.
- Response validation -- The frontend validates server response structure before processing to gracefully handle malformed responses.
Important: The bundle's routes are prefixed with
/admin/inline-relation/. Ensure these routes are behind an authenticated firewall in yoursecurity.yamlconfiguration. Sonata Admin typically places/admin/*behind a firewall, which covers these routes.
Testing
The bundle ships with a comprehensive PHPUnit test suite.
Standalone (after composer install inside the bundle directory):
vendor/bin/phpunit
From a parent project (when the bundle is installed as a dependency):
php vendor/bin/phpunit --configuration vendor/smartlabs/sonata-inline-relation-bundle/phpunit.xml.dist
Test coverage
| Test Class | Tests | Covers |
|---|---|---|
InlineRelationHandlerTest |
37 | Loading, updating (all field types), adding, removing, reordering, ownership validation, radio exclusivity, default values, event dispatch, label_property |
InlineRelationControllerTest |
34 | XHR validation, CSRF protection, success responses, error handling, bundle default merging, safe exception handling, JSON payload validation, confirm_remove option, editable_pivot_fields/min_relations/max_relations options, pivot field whitelist |
EntitySearchProviderTest |
17 | Search queries, exclusion logic, filters, max results, custom query builder, label_property, DQL field name validation |
ConfigurationTest |
7 | Default values, custom values, partial overrides, validation rules (including min/max_relations negative value rejection) |
InlineRelationEventTest |
4 | Event interface compliance, event data accessors |
Total: 99 tests, 336 assertions
License
This bundle is released under the MIT License.
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
Security
If you discover a security vulnerability, please see SECURITY.md for responsible disclosure instructions.

