Sitemap

Managing Virtual Entities in Symfony’s EasyAdmin Without Doctrine Persistence

Introduction

8 min readJan 16, 2025

EasyAdmin is a powerful admin generator for Symfony applications, typically used with Doctrine ORM entities. While it excels at managing database-backed entities, modern applications often need to manage configurations and data outside your primary database. This article shows you how to extend EasyAdmin to handle these “virtual entities” — making it possible to manage all your application’s configurations in one unified interface.

Why Virtual Entities?

The Common Challenge

Many applications face a similar scenario: You have a primary application database for core business logic, but you also need to manage configurations and integrations with various third-party services. These might include:

  • Search service configurations (like Elasticsearch or Algolia)
  • Document processing service settings
  • Email service providers
  • Payment gateway configurations
  • Third-party API credentials and settings
  • Feature flags and tenant-specific configurations

Rather than building a separate interface for these third-party configurations, what if you wanted to:

  1. Keep a consistent admin experience
  2. Maintain the same permission system
  3. Use familiar CRUD patterns
  4. Leverage EasyAdmin’s excellent UI
  5. Keep all configuration management in one place

The Problem with Traditional Approaches

Traditional solutions might involve:

  1. Duplicating Data: Storing API configurations in both the database and the third-party service
  2. Multiple Interfaces: Creating separate admin panels for different types of configurations
  3. Complex Sync Logic: Writing code to keep local and remote configurations in sync
  4. Inconsistent UX: Different interfaces for managing different types of configurations

The Virtual Entity Solution

What Are Virtual Entities
Virtual entities are classes that look like Doctrine entities but don’t persist in a database. They might represent:

  • Data from external APIs
  • Configuration stored in Redis or other caches
  • Data in legacy systems
  • Third-party service configurations

The Challenge
EasyAdmin is tightly coupled with Doctrine by default. When implementing CRUD operations, it expects:

  • Entities to be managed by Doctrine’s EntityManager
  • Database tables to exist for entities
  • Primary keys to be managed by the database

Our goal is to make EasyAdmin work with entities that don’t meet these requirements.

The Solution

Let’s say you have a multi-tenant application that can work with several third-party search services and you want to manage those search services in EasyAdmin along with the rest of your app’s configuration. We’ll create a system that:

  1. Uses MappedSuperclass for virtual entities
  2. Implements a custom entity manager interface
  3. Overrides EasyAdmin’s default CRUD operations
  4. Handles API interactions transparently

Step 1: Doctrine configuration
A crucial step is configuring Doctrine to recognize your virtual entities without actually persisting them to the database. This requires setting up a separate entity manager for virtual entities.

# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver: 'pdo_mysql'
server_version: '8'
charset: utf8mb4

orm:
auto_generate_proxy_classes: true
default_entity_manager: default
entity_managers:
# Regular entity manager for database entities
default:
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
# Separate entity manager for virtual entities
virtual_entity_manager:
mappings:
AppVirtualEntity:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/VirtualEntity/Entity'
prefix: 'App\VirtualEntity\Entity'
alias: AppVirtualEntity

Step 2: Create the Virtual Entity Base Structure

<?php
declare(strict_types = 1);

namespace App\VirtualEntity\Entity;

use App\VirtualEntity\Repository\TenantSearchProviderRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\MappedSuperclass]
#[ORM\Entity(repositoryClass: TenantSearchProviderRepository::class)]
class TenantSearchProvider
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
private int $id;

#[ORM\Column(type: 'string')]
private string $name;

#[ORM\Column(type: 'string')]
private string $tenant;

// ... getters and setters
}

Step 3: Create a Repository for the Virtual Entity
EasyAdmin uses the entity’s repository to retrieve the entity instance when loading the detail or edit page. In our case, we need to implement the find method that retrieves the entity by its ID.

<?php
declare(strict_types = 1);

namespace App\VirtualEntity\Repository;

use App\VirtualEntity\Entity\TenantSearchProvider;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class TenantSearchProviderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TenantSearchProvider::class);
}

public function find(mixed $id, $lockMode = null, $lockVersion = null): ?object
{
// Implementation entity retrieval logic as needed.
// For example use the http client component to make an API call
}
}

Step 4: Define the Virtual Entity Manager Interface
We’ll create an interface that defines the basic crud operation for a virtual entity.

<?php
declare(strict_types = 1);

namespace App\VirtualEntity;

interface VirtualEntityManagerInterface
{
public static function getEntityFqcn(): string;

public function create(object $data): string;

public function delete(object $data): string;

public function list(array $criteria): array;

public function update(object $data): string;
}

Step 5: Implement The Virtual Entity CRUD manager

<?php
declare(strict_types = 1);

namespace App\VirtualEntity\Manager;

use App\Exception\VirtualEntityException;
use App\VirtualEntity\Entity\TenantSearchProvider;
use App\VirtualEntity\VirtualEntityManagerInterface;
use Exception;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class TenantSearchProviderManager implements VirtualEntityManagerInterface
{
public function __construct(private HttpClientInterface $client) {}

public static function getEntityFqcn(): string
{
return TenantSearchProvider::class;
}

public function create(object $data): string
{
// Implement the specific logic to persist a new instance of the virtual entity
// For example, use the http client to make an API POST request
$this->client->request('POST', '/search-providers', [
'json' => [...],
]);
}

public function delete(object $data): string
{
// Similarly, use the http client to make a DELETE request
$this->client->request('DELETE', sprintf('/search-providers/%d', $data->getId()));
}

public function list(array $criteria): array
{
// Grab a filtered paginated list of virtual entities from the API
$response = $this->client->request('GET', '/search-providers', [
'query' => [
'page' => $criteria['page'] ?? 1,
'limit' => $criteria['limit'] ?? 30,
'search' => $criteria['search'] ?? null,
'sort' => $criteria['sort'] ?? null,
'filters' => $criteria['filters'] ?? null,
],
]);

$data = $response->toArray(false);

return [
'items' => array_map(
static fn(array $item) => TenantSearchProvider::fromArray($item),
$data['data'] ?? []
),
'total' => $data['metadata']['total'] ?? 0,
];

}

public function update(object $data): string
{
t$this->client->request('PATCH',sprintf('/search-providers/%d', $data->getId()), [
'json' => [...],
]);
}
}

Step 6: Implement a Paginator for Virtual Entities

<?php
declare(strict_types = 1);

namespace App\VirtualEntity;

use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityPaginatorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\PaginatorDto;
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;

class VirtualEntityPaginator implements EntityPaginatorInterface
{
private array $items;
private int $currentPage = 1;
private int $pageSize = 30;
private int $numResults = 0;
private int $rangeSize = 3;
private int $rangeEdgeSize = 1;

public function __construct(
private AdminContextProvider $adminContextProvider,
private AdminUrlGeneratorInterface $adminUrlGenerator,
private EntityFactory $entityFactory,
private ServiceLocator $virtualEntityManagers
)
{}

public function paginate(PaginatorDto $paginatorDto, QueryBuilder $queryBuilder): EntityPaginatorInterface
{
$this->pageSize = $paginatorDto->getPageSize();
$this->rangeSize = $paginatorDto->getRangeSize();
$this->rangeEdgeSize = $paginatorDto->getRangeEdgeSize();
$this->currentPage = max(1, $paginatorDto->getPageNumber());

$searchDto = $this->adminContextProvider->getContext()?->getSearch();
$criteria = [
'page' => $this->currentPage,
'limit' => $this->pageSize,
];

if ($searchDto) {
$criteria['search'] = $searchDto->getQuery();
$criteria['sort'] = $searchDto->getSort();
$filters = $searchDto->getAppliedFilters();
if (!empty($filters)) {
$criteria['filters'] = $filters;
}
}

$result = $this->getVirtualEntityManager()->list($criteria);
$this->items = $result['items'];
$this->numResults = $result['total'];

return $this;
}

public function generateUrlForPage(int $page): string
{
return $this->adminUrlGenerator->set(EA::PAGE, $page)->generateUrl();
}

public function getCurrentPage(): int
{
return $this->currentPage;
}

public function getLastPage(): int
{
return max(1, (int)ceil($this->numResults / $this->pageSize));
}

public function getPageRange(?int $pagesOnEachSide = null, ?int $pagesOnEdges = null): iterable
{
$pagesOnEachSide = $pagesOnEachSide ?? $this->rangeSize;
$pagesOnEdges = $pagesOnEdges ?? $this->rangeEdgeSize;

$lastPage = $this->getLastPage();
if ($lastPage <= 1) {
return [1];
}

$startPage = max(1, $this->currentPage - $pagesOnEachSide);
$endPage = min($lastPage, $this->currentPage + $pagesOnEachSide);

$range = range($startPage, $endPage);

if ($pagesOnEdges > 0) {
if ($startPage > 1) {
$firstPages = range(1, min($pagesOnEdges, $startPage - 1));
$range = array_merge($firstPages, $range);
}

if ($endPage < $lastPage) {
$lastPages = range(max($endPage + 1, $lastPage - $pagesOnEdges + 1), $lastPage);
$range = array_merge($range, $lastPages);
}
}

return array_unique($range);
}

public function getPageSize(): int
{
return $this->pageSize;
}

public function hasPreviousPage(): bool
{
return $this->currentPage > 1;
}

public function getPreviousPage(): int
{
return max(1, $this->currentPage - 1);
}

public function hasNextPage(): bool
{
return $this->currentPage < $this->getLastPage();
}

public function getNextPage(): int
{
return min($this->getLastPage(), $this->currentPage + 1);
}

public function hasToPaginate(): bool
{
return $this->numResults > $this->pageSize;
}

public function isOutOfRange(): bool
{
return $this->currentPage < 1 || $this->currentPage > $this->getLastPage();
}

public function getNumResults(): int
{
return $this->numResults;
}

public function getResults(): ?iterable
{
return $this->items;
}

public function getResultsAsJson(): string
{
$jsonResult = ['results' => []];

foreach ($this->getResults() ?? [] as $entityInstance) {
$entityDto = $this->entityFactory->createForEntityInstance($entityInstance);

$jsonResult['results'][] = [
EA::ENTITY_ID => $entityDto->getPrimaryKeyValueAsString(),
'entityAsString' => $entityDto->toString(),
];
}

$jsonResult['next_page'] = !$this->hasNextPage()
? null
:
$this->adminUrlGenerator->set(EA::PAGE, $this->getNextPage())
->removeReferrer()
->generateUrl();

return json_encode($jsonResult, \JSON_THROW_ON_ERROR);
}

private function getVirtualEntityManager(): ?VirtualEntityManagerInterface
{
$entityDto = $this->adminContextProvider->getContext()?->getEntity();
if (!$entityDto) {
return null;
}

return $this->virtualEntityManagers->get($entityDto->getFqcn());
}
}

Step 7: Implement a Base Crud Controller for Virtual Entities

<?php
declare(strict_types = 1);

namespace App\Controller\Admin;

use App\VirtualEntity\VirtualEntityManagerInterface;
use App\VirtualEntity\VirtualEntityPaginator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Event\AfterCrudActionEvent;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeCrudActionEvent;
use EasyCorp\Bundle\EasyAdminBundle\Exception\ForbiddenActionException;
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\FilterFactory;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Security\Permission;
use Symfony\Component\DependencyInjection\ServiceLocator;
use function Symfony\Component\String\u;

abstract class BaseVirtualEntityCrudController extends AbstractCrudController implements LoggerAwareInterface
{
public function __construct(
private AdminUrlGenerator $adminUrlGenerator,
private EntityManagerInterface $entityManager,
private VirtualEntityPaginator $paginator,
private ServiceLocator $virtualEntityManagers
)
{}

public function index(AdminContext $context)
{
$event = new BeforeCrudActionEvent($context);
$this->container->get('event_dispatcher')->dispatch($event);
if ($event->isPropagationStopped()) {
return $event->getResponse();
}

if (!$this->isGranted(Permission::EA_EXECUTE_ACTION, ['action' => Action::INDEX, 'entity' => null, 'entityFqcn' => $context->getEntity()->getFqcn()])) {
throw new ForbiddenActionException($context);
}

$fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
$filters = $this->container->get(FilterFactory::class)->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity());

$paginatorDto = $context->getCrud()->getPaginator();
$paginatorDto->setPageNumber($context->getRequest()->query->getInt('page', 1));
$paginator = $this->paginator->paginate(
$paginatorDto,
new QueryBuilder($this->entityManager),
);

$entities = $this->container->get(EntityFactory::class)->createCollection(
$context->getEntity(),
$paginator->getResults()
);

$this->container->get(EntityFactory::class)->processFieldsForAll($entities, $fields);
$processedFields = $entities->first()?->getFields() ?? FieldCollection::new([]);
$context->getCrud()->setFieldAssets($this->getFieldAssets($processedFields));
$actions = $this->container->get(EntityFactory::class)->processActionsForAll($entities, $context->getCrud()->getActionsConfig());

$responseParameters = $this->configureResponseParameters(KeyValueStore::new([
'pageName' => Crud::PAGE_INDEX,
'templateName' => 'crud/index',
'entities' => $entities,
'paginator' => $paginator,
'global_actions' => $actions->getGlobalActions(),
'batch_actions' => $actions->getBatchActions(),
'filters' => $filters,
]));

$event = new AfterCrudActionEvent($context, $responseParameters);
$this->container->get('event_dispatcher')->dispatch($event);
if ($event->isPropagationStopped()) {
return $event->getResponse();
}

return $responseParameters;
}

public function persistEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
$this->getVirtualEntityManager()->create($entityInstance);
}

public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
$this->getVirtualEntityManager()->update($entityInstance);
}

public function deleteEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
$this->getVirtualEntityManager()->delete($entityInstance);
}

protected function getVirtualEntityManager(): VirtualEntityManagerInterface
{
$entityDto = $this->getContext()->getEntity();

return $this->virtualEntityManagers->get($entityDto->getFqcn());
}

protected function getVirtualEntityName(): string
{
return u($this->getContext()->getEntity()->getFqcn())->afterLast('\\')->toString();
}
}

Step 8: Implement the Crud controller for each Virtual Entity

<?php
declare(strict_types = 1);

namespace App\Controller\Admin;

use App\VirtualEntity\Entity\TenantSearchProvider;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\Attribute\Required;

class TenantSearchProviderCrudController extends BaseVirtualEntityCrudController
{
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield TextField::new('name');
yield TextField::new('tenant')
// addition fields as needed
}

public function configureCrud(Crud $crud): Crud
{
return $crud
->setSearchFields(['name', 'tenant'])
->setDefaultSort(['id' => 'ASC']);
}

public function configureFilters(Filters $filters): Filters
{
return $filters
->add('name')
->add('tenant');
}

public static function getEntityFqcn(): string
{
return TenantSearchProvider::class;
}
}

Step 9: Auto-configure Virtual Entity Managers for easy Dependency Injection

# config/services.yaml
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
$virtualEntityManagers: !tagged_locator { tag: 'virtual_entity_manager', index_by: 'key', default_index_method: 'getEntityFqcn' }

_instanceof:
App\VirtualEntity\VirtualEntityManagerInterface:
tags: [ 'virtual_entity_manager' ]

Conclusion

This approach allows you to leverage EasyAdmin’s powerful interface while working with data that doesn’t fit the traditional Doctrine model. It’s particularly useful for:

  • Microservice architectures
  • Legacy system integration
  • Third-party API management
  • Configuration interfaces

Remember to handle errors gracefully and provide clear feedback to users when operations fail.

Next Steps

  • Implement caching for API responses
  • Add batch operations support
  • Create custom field types for specific API features
  • Implement real-time updates using web sockets
Medium Logo
Medium Logo

Sign up to discover human stories that deepen your understanding of the world.

Responses (1)

Write a response