smartlabs/sonata-inline-relation-bundle

Inline editing of complex associations (OneToMany/ManyToMany with pivot entities) in Sonata Admin list views

Maintainers

Package info

github.com/smartlabsAT/sonata-inline-relation-bundle

Type:symfony-bundle

pkg:composer/smartlabs/sonata-inline-relation-bundle

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.19 2026-03-12 13:14 UTC

README

Latest Stable Version Tests License PHP Version

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
Inline category editing with hierarchical search, drag-and-drop sorting, radio and checkbox pivot fields Inline tag editing with number, text and select pivot 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 true at 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_field for 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 the stylesheets / javascripts blocks, 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_field is null, 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 entity
  • getOptions(): array -- the field configuration array

Event-specific data

InlineRelationUpdateEvent adds:

  • getPivotEntity(): object -- the updated pivot entity
  • getField(): string -- the pivot field name that was changed
  • getValue(): 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

  1. List view renders -- Twig template calls sonata_inline_relation_entries() and sonata_inline_relation_config() to display labels and embed the JSON configuration
  2. User clicks pencil icon -- JavaScript reads the embedded config and opens a popover panel anchored to the trigger cell
  3. Popover loads entries -- GET request to /admin/inline-relation/load
  4. 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
  5. Controller processes -- validates CSRF, validates JSON payload, checks admin access, delegates to InlineRelationHandler
  6. Handler executes -- performs database operation, dispatches event, returns updated entries
  7. 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 with edit access.
  • Access control -- Every request calls $admin->checkAccess('edit', $object), delegating authorization to Sonata Admin's built-in security layer. AccessDeniedException returns 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_fields can 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 your security.yaml configuration. 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.