Skip to main content
Version: Next

Integrate Direct Edit Button into Custom Application

Direct Edit can be driven from your own frontend, not only from Pimcore Studio. You supply a permission service, expose the bundle's session model through your own controller, and forward the desktop client's events to your users.

warning

The bundle no longer ships frontend assets. /bundles/pimcoredirectedit/js/bootstrap-modal.js, /bundles/pimcoredirectedit/css/style.css and the createDirectEditButton() helper were removed together with the Admin Classic frontend, see Upgrade Notes. Build your own UI against the endpoints you expose in step 3.

1 Create a Permission Service

Create a permission service that implements PermissionServiceInterface with these four methods:

  • getApplicationPrefix(): string returns a unique prefix for your application. pimcore_direct_edit_ is reserved for Studio sessions, so do not start yours with it.
  • getUserId(): ?int returns a unique id of the user in your application. The prefix and this id form the owner of an editing session, so the pair has to stay stable for the duration of a session and has to differ between your users even when they share one Pimcore user.
  • mapPimcoreUserId(): int maps to the Pimcore user that owns the asset versions. Return 0 (system user) if your application has no matching Pimcore user.
  • hasAssetPermission(Asset $asset): bool decides whether the current user may edit the asset. Return false whenever getUserId() is null: every session method calls it first and then relies on that id.

The following example identifies the user through the Symfony security token of the host application. Its users are its own and all map to one Pimcore service user, which is the case the prefix and the application id exist for.

namespace App\DirectEdit\Permission;

use App\Security\PortalUser;
use Pimcore\Bundle\DirectEditBundle\Service\Permission\PermissionServiceInterface;
use Pimcore\Model\Asset;
use Pimcore\Model\User;
use Symfony\Bundle\SecurityBundle\Security;

class FrontendPermissionService implements PermissionServiceInterface
{
public const string PREFIX = 'my_app_';

public function __construct(private readonly Security $security)
{
}

public function hasAssetPermission(Asset $asset): bool
{
$pimcoreUser = User::getById($this->mapPimcoreUserId());
if ($this->getUserId() === null || !$pimcoreUser) {
return false;
}

return $pimcoreUser->isAllowed(self::PERMISSION_NAME)
&& $asset->isAllowed('publish', $pimcoreUser);
}

// Your own user, not the Pimcore one: this is what keeps two sessions on one Pimcore user apart.
public function getUserId(): ?int
{
$user = $this->security->getUser();

return $user instanceof PortalUser ? $user->getId() : null;
}

public function getApplicationPrefix(): string
{
return self::PREFIX;
}

// The Pimcore user that owns the asset versions. All of your users may map to the same one.
public function mapPimcoreUserId(): int
{
return User::getByName('portal-service-user')?->getId() ?? 0;
}
}

2 Configure a DirectEditService Instance

Pimcore\Bundle\DirectEditBundle\Service\Studio\DirectEditService is the session model behind the Studio endpoints: it issues the session token, builds the desktop client link, tracks the upload, saves it to the asset, and resolves conflicts. Configure a second instance with your permission service. Every other argument autowires; only the permission service has to be passed, because the bundle's alias for it points at the Studio adapter.

services:
App\DirectEdit\Permission\FrontendPermissionService: ~

app.direct_edit.service:
class: Pimcore\Bundle\DirectEditBundle\Service\Studio\DirectEditService
autowire: true
arguments:
$permissionService: '@App\DirectEdit\Permission\FrontendPermissionService'

3 Expose the Session Model Through Your Controller

Call the instance from your own controller. The methods throw the Studio API exceptions (Pimcore\Bundle\StudioBackendBundle\Exception\Api\*), so map them to the responses your frontend expects, and serialize the returned schema objects (DirectEditLink, DirectEditStatus, DirectEditConflictResolution) with the Symfony serializer.

namespace App\Controller\DirectEdit;

use Pimcore\Bundle\DirectEditBundle\Service\Studio\DirectEditServiceInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;

#[Route('/frontend_direct_edit')]
class FrontendDirectEditController
{
public function __construct(
#[Autowire(service: 'app.direct_edit.service')]
private readonly DirectEditServiceInterface $directEdit,
private readonly SerializerInterface $serializer,
) {
}

#[Route('/generate_link/{assetId}', methods: ['POST'])]
public function generateLink(int $assetId): JsonResponse
{
return $this->json($this->directEdit->generateLink($assetId));
}

#[Route('/status/{assetId}', methods: ['GET'])]
public function status(int $assetId): JsonResponse
{
return $this->json($this->directEdit->getStatus($assetId));
}

#[Route('/confirm_upload/{assetId}', methods: ['POST'])]
public function confirmUpload(int $assetId): JsonResponse
{
return $this->json($this->directEdit->confirmUpload($assetId));
}

#[Route('/resolve_conflict/{assetId}', methods: ['POST'])]
public function resolveConflict(int $assetId, Request $request): JsonResponse
{
// 'overwrite' or 'save-as-version', see ResolveConflictRequest
return $this->json($this->directEdit->resolveConflict($assetId, (string) $request->getPayload()->get('strategy')));
}

#[Route('/cancel_edit/{assetId}', methods: ['POST'])]
public function cancelEdit(int $assetId): JsonResponse
{
return $this->json($this->directEdit->cancelEdit($assetId));
}

private function json(object $schema): JsonResponse
{
return new JsonResponse($this->serializer->serialize($schema, 'json'), json: true);
}
}

The flow is the one Studio drives: generateLink returns the pimcorefile:// link that opens the desktop client; getStatus reports link-generated, editing, uploaded, confirmed or cancelled and whether confirmUpload may be called; confirmUpload saves the upload to the asset or returns hasConflict when another user changed the asset in the meantime; resolveConflict then overwrites or saves as a version; cancelEdit ends the session.

4 Forward Desktop-Client Events to Your Users

While a file is being edited, the desktop client reports two events to the bundle: direct-edit.start-editing when it downloads the file and direct-edit.upload-complete when it uploads a new version. The bundle publishes them on the Studio hub, but only to Studio users: a session started through your permission service carries your prefix, so DirectEditService skips it. (The bundle also publishes its deprecated legacy topic for every session until 2027.1; nothing subscribes to it unless you built on the trait.)

Decorate Pimcore\Bundle\DirectEditBundle\Service\DirectEditEventNotifierInterface and republish the events for your own sessions on a topic your frontend subscribes to. Keep the payload shape so one frontend handler serves both hubs.

namespace App\DirectEdit;

use App\DirectEdit\Permission\FrontendPermissionService;
use Pimcore\Bundle\DirectEditBundle\Entity\AssetToken;
use Pimcore\Bundle\DirectEditBundle\Service\DirectEditEventNotifierInterface;
use Pimcore\Bundle\DirectEditBundle\Service\FileService;
use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\PublishServiceInterface;
use Pimcore\Model\Asset;
use Psr\Log\LoggerInterface;

final class FrontendDirectEditNotifier implements DirectEditEventNotifierInterface
{
public function __construct(
private readonly DirectEditEventNotifierInterface $inner,
private readonly PublishServiceInterface $publishService,
private readonly FileService $fileService,
private readonly LoggerInterface $logger,
) {
}

public function notifyStartEditing(AssetToken $assetToken): array
{
return [
...$this->inner->notifyStartEditing($assetToken),
...$this->publish($assetToken, 'direct-edit.start-editing'),
];
}

public function notifyUploadComplete(AssetToken $assetToken): array
{
$file = $this->fileService->findDirectEditVersionFile($assetToken);

return [
...$this->inner->notifyUploadComplete($assetToken),
...$this->publish($assetToken, 'direct-edit.upload-complete', [
'modificationDate' => $file ? (new \DateTime('@' . $file->getMTime()))->format(\DateTime::ATOM) : null,
]),
];
}

/**
* @return array<string> the desktop client has already done its work, so a failed notification is a warning
*/
private function publish(AssetToken $assetToken, string $type, array $extra = []): array
{
// The token owner is "<prefix><your user id>"; sessions of other hosts are not yours to announce.
$prefix = FrontendPermissionService::PREFIX;
if (!str_starts_with($assetToken->getUserId(), $prefix)) {
return [];
}

// Another host's prefix may start with yours, so require a plain numeric remainder.
$userId = substr($assetToken->getUserId(), strlen($prefix));
if (!ctype_digit($userId)) {
return [];
}

try {
$this->publishService->publish('my-app/user/' . $userId, [
'type' => $type,
'assetId' => $assetToken->getAssetId(),
'fileName' => Asset::getById($assetToken->getAssetId())?->getFilename() ?? '',
...$extra,
]);
} catch (\Exception $e) {
$this->logger->error('Could not notify my-app of ' . $type . ': ' . $e->getMessage());

return ['Could not notify my-app of ' . $type . '.'];
}

return [];
}
}
services:
App\DirectEdit\FrontendDirectEditNotifier:
decorates: Pimcore\Bundle\DirectEditBundle\Service\DirectEditEventNotifierInterface
arguments:
$inner: '@.inner'

publish() sends a private update, so the browser is only allowed to receive it if the Mercure token it subscribes with names that topic. Studio builds that token from the topic providers registered in its container, and its own providers know only Studio's topics, so register one for yours:

namespace App\DirectEdit;

use App\Security\PortalUser;
use Pimcore\Bundle\StudioBackendBundle\Mercure\Provider\ClientTopicProviderInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('pimcore.studio_backend.mercure.topic.provider')]
final class FrontendTopicProvider implements ClientTopicProviderInterface
{
public function __construct(private readonly Security $security)
{
}

// Only ever the current user's own topic; this is what authorises their subscription.
public function getClientSubscribableTopic(): array
{
$user = $this->security->getUser();

return $user instanceof PortalUser ? ['my-app/user/' . $user->getId()] : [];
}

public function getClientPublishableTopic(): array
{
return [];
}
}

Only the subscriber side needs this; the token the server publishes with already covers the topic. See Studio Backend Mercure Setup for how Studio issues the tokens themselves.

A cancel is not a desktop-client event: cancelEdit() announces it to Studio users only, and your frontend learns of it from the next getStatus call.

Legacy: FileEditControllerTrait

warning

Pimcore\Bundle\DirectEditBundle\Controller\FileEditControllerTrait is deprecated since 2026.3 and will be removed in 2027.1, together with the bundle's own Mercure hub and the modal protocol built on it. Using the trait logs a deprecation. Migrate to the steps above.

Before 2026.3, a custom frontend used the trait: a controller with a route prefix that pulls in the legacy routes (/settings, /generate_link/{assetId}, /cancel_edit/{assetId}, /confirm_edit/{assetId}, /confirm_overwrite_after_local_edit/{assetId}, /confirm_versionsave_after_local_edit/{assetId}, /event_server_has_gone/{assetId}), responds with rendered modals, and notifies the browser through the bundle's own Mercure topic http://www.pimcore.com/direct-edit/client-upload/user/<userId>.

    App\Controller\DirectEdit\FrontendDirectEditController:
arguments:
$permissionService: '@App\DirectEdit\Permission\FrontendPermissionService'
namespace App\Controller\DirectEdit;

use Pimcore\Bundle\DirectEditBundle\Controller\FileEditControllerTrait;
use Pimcore\Controller\FrontendController;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/frontend_direct_edit')]
class FrontendDirectEditController extends FrontendController
{
use FileEditControllerTrait;
}