Skip to content

Extend Collaborative editing

Thanks to the ability to extend the Collaborative editing feature, you can add even more functionalities that can improve workflows not only within content editing but also when working, for example, with the product. In the example below, you will learn how to extend this feature to enable a shared Cart functionality in the Commerce system.

Tip

If you prefer learning from videos, you can check a presentation from Ibexa Summit 2025 that covers the Collaborative editing feature:

Collaboration: greater than the sum of the parts by Marek Nocoń

Create tables to hold Cart session data

First, you need to set up the database layer and define the collaboration context, in this example, Cart. Create the necessary tables to store the data and to link the collaboration session with the Cart you want to share.

In the data/schema.sql file create a database table to store a reference to the session context. In this example, it represents the shopping Cart (identified by the Cart identifier) and an additional numeric ID stored in the database.

1
2
3
4
5
6
7
8
    CREATE TABLE ibexa_collaboration_cart
    (
        id INT NOT NULL PRIMARY KEY,
        cart_identifier VARCHAR(255) NOT NULL,
        CONSTRAINT ibexa_collaboration_cart_ibexa_collaboration_id_fk
            FOREIGN KEY (id) REFERENCES ibexa_collaboration (id)
                ON DELETE CASCADE
    ) COLLATE = utf8mb4_general_ci;
1
2
3
4
5
6
7
CREATE TABLE ibexa_collaboration_cart (
id INTEGER NOT NULL PRIMARY KEY,
cart_identifier VARCHAR(255) NOT NULL,
CONSTRAINT ibexa_collaboration_cart_ibexa_collaboration_id_fk
    FOREIGN KEY (id) REFERENCES ibexa_collaboration (id)
    ON DELETE CASCADE
);

Set up the persistance layer

To extend Collaborative editing feature to support shared Cart collaboration, you need to prepare the persistence layer. This layer handles how the data about collaboration session and the Cart is stored, retrieved, and managed in the database.

It ensures that when a user creates, joins, or updates a Cart session, the system can track session status, participants, and permissions.

Implement the persistence gateway

The Gateway is the layer that connects the collaboration feature to the database. It handles all the create, read, update, and delete operations for collaboration sessions, ensuring that session data is stored and retrieved correctly.

It also uses a Discriminator to specify the session type, so it can interact with the correct tables and data structures. This way, the system knows which Gateway to use to get or save the right data for each session type.

When creating the Database Gateways and mappers, you can use the build-in service tag: ibexa.collaboration.persistence.session.gateway:

1
2
    tags:
    - { name: 'ibexa.collaboration.persistence.session.gateway' }

In the Collaboration/Cart/Persistence/Gateway directory create the following files:

  • DatabaseGateway - implements the gateway logic for getting and retrieving shared Cart collaboration data from the database, using a Discriminator to indicate the type of session (in this case, a Cart session):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Persistence\Gateway;

use App\Collaboration\Cart\Persistence\Values\CartSessionCreateStruct as CreateStruct;
use App\Collaboration\Cart\Persistence\Values\CartSessionUpdateStruct as UpdateStruct;
use Doctrine\DBAL\Types\Types;
use Ibexa\Collaboration\Persistence\Session\Inner\GatewayInterface;
use Ibexa\Collaboration\Persistence\Values\AbstractSessionCreateStruct;
use Ibexa\Collaboration\Persistence\Values\AbstractSessionUpdateStruct;
use Ibexa\Contracts\CorePersistence\Gateway\AbstractDoctrineDatabase;
use Ibexa\Contracts\CorePersistence\Gateway\DoctrineSchemaMetadata;
use Ibexa\Contracts\CorePersistence\Gateway\DoctrineSchemaMetadataInterface;

/**
 * @phpstan-type TRow array{
 *     id: int,
 *     cart_identifier: string
 * }
 *
 * @template-extends \Ibexa\Contracts\CorePersistence\Gateway\AbstractDoctrineDatabase<TRow>
 *
 * @template-implements \Ibexa\Collaboration\Persistence\Session\Inner\GatewayInterface<TRow, CreateStruct, UpdateStruct>
 */
final class DatabaseGateway extends AbstractDoctrineDatabase implements GatewayInterface
{
    public const DISCRIMINATOR = 'cart';

    protected function buildMetadata(): DoctrineSchemaMetadataInterface
    {
        return new DoctrineSchemaMetadata(
            $this->connection,
            null,
            $this->getTableName(),
            [
                DatabaseSchema::COLUMN_ID => Types::INTEGER,
                DatabaseSchema::COLUMN_CART_IDENTIFIER => Types::STRING,
            ],
            [DatabaseSchema::COLUMN_ID]
        );
    }

    protected function getTableName(): string
    {
        return DatabaseSchema::TABLE_NAME;
    }

    public function getDiscriminator(): string
    {
        return self::DISCRIMINATOR;
    }

    /**
     * @param \App\Collaboration\Cart\Persistence\Values\CartSessionCreateStruct $createStruct
     */
    public function create(int $sessionId, AbstractSessionCreateStruct $createStruct): void
    {
        $this->doInsert([
            DatabaseSchema::COLUMN_ID => $sessionId,
            DatabaseSchema::COLUMN_CART_IDENTIFIER => $createStruct->getCartIdentifier(),
        ]);
    }

    /**
     * @param \Ibexa\Collaboration\Persistence\Values\AbstractSessionUpdateStruct $updateStruct
     */
    public function update(AbstractSessionUpdateStruct $updateStruct): void
    {
        // There is nothing to update
    }
}
  • DatabaseSchema - defines and creates the database tables needed to store shared Cart collaboration session data:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Persistence\Gateway;

final class DatabaseSchema
{
    public const TABLE_NAME = 'ibexa_collaboration_cart';

    public const COLUMN_ID = 'id';
    public const COLUMN_CART_IDENTIFIER = 'cart_identifier';

    private function __construct()
    {
        // This class is not intended to be instantiated
    }
}

Define database Value objects

Value objects describe how collaboration session data is represented in the database. Persistence gateway uses them to store, retrieve, and manipulate session information, such as the session ID, associated Cart, participants, and scopes.

In the Collaboration/Cart/Persistence/Values directory create the following Value Objects:

  • CartSession - represents the Cart collaboration session data:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use DateTimeInterface;
use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\Participant\ParticipantCollectionInterface;
use Ibexa\Contracts\Collaboration\Session\AbstractSession;
use Ibexa\Contracts\Core\Repository\Values\User\User;

final class CartSession extends AbstractSession
{
    private CartInterface $cart;

    public function __construct(
        int $id,
        CartInterface $cart,
        string $token,
        User $owner,
        ParticipantCollectionInterface $participants,
        bool $isActive,
        bool $hasPublicLink,
        DateTimeInterface $createdAt,
        DateTimeInterface $updatedAt
    ) {
        parent::__construct($id, $token, $owner, $participants, $isActive, $hasPublicLink, $createdAt, $updatedAt);

        $this->cart = $cart;
    }

    public function getCart(): CartInterface
    {
        return $this->cart;
    }
}
  • CartSessionCreateStruct - defines the data needed to create a new Cart collaboration session:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\Session\AbstractSessionCreateStruct;

final class CartSessionCreateStruct extends AbstractSessionCreateStruct
{
    private CartInterface $cart;

    public function __construct(CartInterface $cart)
    {
        parent::__construct();

        $this->cart = $cart;
    }

    public function getCart(): CartInterface
    {
        return $this->cart;
    }

    public function setCart(CartInterface $cart): void
    {
        $this->cart = $cart;
    }

    public function getType(): string
    {
        return CartSessionType::IDENTIFIER;
    }
}
  • CartSessionUpdateStruct - defines the data used to update an existing Cart collaboration session:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Collaboration\Session\AbstractSessionUpdateStruct;

final class CartSessionUpdateStruct extends AbstractSessionUpdateStruct
{
    public function getType(): string
    {
        return CartSessionType::IDENTIFIER;
    }
}

Create the Cart session Struct objects

The next step involves the Public API — you need to integrate it with the database to store data and retrieve it from the tables created before. You need to create new files to define the data that is passed into the public API which are then used by the SessionService and public API handlers.

In the Collaboration/Cart directory create the following Session Structs:

  • CartSessionCreateStruct - holds all necessary properties (like session token, participants, scopes, and the Cart reference) needed by the SessionService to create the shared Cart session:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\Session\AbstractSessionCreateStruct;

final class CartSessionCreateStruct extends AbstractSessionCreateStruct
{
    private CartInterface $cart;

    public function __construct(CartInterface $cart)
    {
        parent::__construct();

        $this->cart = $cart;
    }

    public function getCart(): CartInterface
    {
        return $this->cart;
    }

    public function setCart(CartInterface $cart): void
    {
        $this->cart = $cart;
    }

    public function getType(): string
    {
        return CartSessionType::IDENTIFIER;
    }
}
  • CartSessionUpdateStruct - defines the properties used to update an existing Cart collaboration session, including participants, scopes, and metadata:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Collaboration\Session\AbstractSessionUpdateStruct;

final class CartSessionUpdateStruct extends AbstractSessionUpdateStruct
{
    public function getType(): string
    {
        return CartSessionType::IDENTIFIER;
    }
}
  • CartSession - represents a Cart collaboration session, storing its ID, token, associated Cart, participants, and scope:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use DateTimeInterface;
use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\Participant\ParticipantCollectionInterface;
use Ibexa\Contracts\Collaboration\Session\AbstractSession;
use Ibexa\Contracts\Core\Repository\Values\User\User;

final class CartSession extends AbstractSession
{
    private CartInterface $cart;

    public function __construct(
        int $id,
        CartInterface $cart,
        string $token,
        User $owner,
        ParticipantCollectionInterface $participants,
        bool $isActive,
        bool $hasPublicLink,
        DateTimeInterface $createdAt,
        DateTimeInterface $updatedAt
    ) {
        parent::__construct($id, $token, $owner, $participants, $isActive, $hasPublicLink, $createdAt, $updatedAt);

        $this->cart = $cart;
    }

    public function getCart(): CartInterface
    {
        return $this->cart;
    }
}
  • CartSessionType - defines the type of the collaboration session (in this case indicating it’s a Cart session):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Collaboration\Session\SessionScopeInterface;

final class CartSessionType implements SessionScopeInterface
{
    public const SCOPE_VIEW = 'view';
    public const SCOPE_EDIT = 'edit';

    public const IDENTIFIER = 'cart';

    private function __construct()
    {
        // This class is not intended to be instantiated
    }

    public function getDefaultScope(): string
    {
        return self::SCOPE_VIEW;
    }

    public function isValidScope(string $scope): bool
    {
        return in_array($scope, $this->getScopes(), true);
    }

    public function getScopes(): array
    {
        return [
            self::SCOPE_VIEW,
            self::SCOPE_EDIT,
        ];
    }
}

Allow participants to access the Cart

To start collaborating, you need to work on permissions. This involves decorating the PermissionResolver and CartResolver.

This step makes sure that if a Cart is part of a Cart collaboration session, users can access it due to the given permission, and in all other cases, it falls back to the default implementation.

Decorating permissions

Be careful when decorating permissions to change the behavior only as necessary, ensuring the Cart is shared only with the intended users.

In the src/Collaboration/Cart directory, create the following files:

  • PermissionResolverDecorator – customizes the permission resolver to handle access rules for Cart collaboration sessions, allowing participants to view or edit shared Carts while preserving default permission checks for all other cases. Here you can decide what scope is available for this collaboration session by choosing between view or edit.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Cart\Permission\Policy\Cart\Edit as CartEdit;
use Ibexa\Contracts\Cart\Permission\Policy\Cart\View as CartView;
use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\SessionServiceInterface;
use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException;
use Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException;
use Ibexa\Contracts\ProductCatalog\Permission\Policy\PolicyInterface;
use Ibexa\Contracts\ProductCatalog\PermissionResolverInterface;
use Symfony\Component\HttpFoundation\RequestStack;

final class PermissionResolverDecorator implements PermissionResolverInterface
{
    public const COLLABORATION_SESSION_ID = 'collaboration_session';

    private bool $nested = false;

    public function __construct(
        private PermissionResolverInterface $innerPermissionResolver,
        private SessionServiceInterface $sessionService,
        private RequestStack $requestStack,
    ) {
    }

    public function canUser(PolicyInterface $policy): bool
    {
        if ($this->nested === false && $this->isCartPolicy($policy) && $this->isSharedCart($policy->getObject())) {
            return true;
        }

        return $this->innerPermissionResolver->canUser($policy);
    }

    public function assertPolicy(PolicyInterface $policy): void
    {
        if ($this->nested === false && $this->isCartPolicy($policy) && $this->isSharedCart($policy->getObject())) {
            return;
        }

        $this->innerPermissionResolver->assertPolicy($policy);
    }

    private function isCartPolicy(PolicyInterface $policy): bool
    {
        return $policy instanceof CartView || $policy instanceof CartEdit;
    }

    private function isSharedCart(?CartInterface $cart): bool
    {
        if ($cart === null) {
            return false;
        }

        try {
            $this->nested = true;

            /** @var \App\Collaboration\Cart\CartSession $session */
            $session = $this->getCurrentCartCollaborationSession();
            if ($session !== null) {
                try {
                    return $cart->getId() === $session->getCart()->getId();
                } catch (NotFoundException $e) {
                }
            }
        } finally {
            $this->nested = false;
        }

        return false;
    }

    private function getCurrentCartCollaborationSession(): ?CartSession
    {
        $token = $this->requestStack->getSession()->get(self::COLLABORATION_SESSION_ID);
        if ($token === null) {
            return null;
        }

        try {
            $session = $this->sessionService->getSessionByToken($token);
            if ($session instanceof CartSession) {
                return $session;
            }
        } catch (NotFoundException|UnauthorizedException) {
        }

        return null;
    }
}
  • CartResolverDecorator – extends the permission resolver to allow access to shared Carts in collaboration sessions, it checks if a Cart belongs to a collaboration session.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart;

use Ibexa\Contracts\Cart\CartResolverInterface;
use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Collaboration\SessionServiceInterface;
use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException;
use Ibexa\Contracts\Core\Repository\Values\User\User;
use Symfony\Component\HttpFoundation\RequestStack;

final class CartResolverDecorator implements CartResolverInterface
{
    public function __construct(
        private CartResolverInterface $innerCartResolver,
        private SessionServiceInterface $sessionService,
        private RequestStack $requestStack
    ) {
    }

    public function resolveCart(?User $user = null): CartInterface
    {
        if ($this->hasSharedCart()) {
            return $this->getSharedCart() ?? $this->innerCartResolver->resolveCart($user);
        }

        return $this->innerCartResolver->resolveCart($user);
    }

    private function getSharedCart(): ?CartInterface
    {
        /** @var \App\Collaboration\Cart\CartSession $session */
        try {
            $session = $this->sessionService->getSessionByToken(
                $this->requestStack->getSession()->get(PermissionResolverDecorator::COLLABORATION_SESSION_ID)
            );

            return $session->getCart();
        } catch (NotFoundException|\Ibexa\ProductCatalog\Exception\UnauthorizedException $e) {
            return null;
        }
    }

    private function hasSharedCart(): bool
    {
        return $this->requestStack->getSession()->has(PermissionResolverDecorator::COLLABORATION_SESSION_ID);
    }
}

Create mappers

Mappers are used to return session data into the format the database needs and to send it to the repository.

In the src\Collaboration\Cart\Mapper folder create four mappers:

  • CartProxyMapper - creates a simplified version of the Cart with only the necessary data to reduce memory usage in collaboration sessions.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Mapper;

use Ibexa\Contracts\Cart\CartServiceInterface;
use Ibexa\Contracts\Cart\Value\CartInterface;
use Ibexa\Contracts\Core\Repository\Repository;
use Ibexa\Core\Repository\ProxyFactory\ProxyGeneratorInterface;
use ProxyManager\Proxy\LazyLoadingInterface;

final class CartProxyMapper implements CartProxyMapperInterface
{
    public function __construct(
        private Repository $repository,
        private CartServiceInterface $cartService,
        private ProxyGeneratorInterface $proxyGenerator
    ) {
    }

    public function createCartProxy(string $identifier): CartInterface
    {
        $initializer = function (
            &$wrappedObject,
            LazyLoadingInterface $proxy,
            $method,
            array $parameters,
            &$initializer
        ) use ($identifier): bool {
            $initializer = null;
            $wrappedObject = $this->repository->sudo(fn () => $this->cartService->getCart($identifier));

            return true;
        };

        return $this->proxyGenerator->createProxy(CartInterface::class, $initializer);
    }
}
  • CartProxyMapperInterface - defines how a Cart should be converted into a simplified object used in collaboration session and specifies what methods the mapper must implement.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Mapper;

use Ibexa\Contracts\Cart\Value\CartInterface;

interface CartProxyMapperInterface
{
    public function createCartProxy(string $identifier): CartInterface;
}
  • CartSessionDomainMapper - builds the session object that the app works with.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Mapper;

use App\Collaboration\Cart\CartSession;
use Ibexa\Collaboration\Mapper\Domain\ParticipantCollectionDomainMapperInterface;
use Ibexa\Collaboration\Mapper\Domain\SessionDomainMapperInterface;
use Ibexa\Collaboration\Mapper\Domain\UserProxyDomainMapperInterface;
use Ibexa\Collaboration\Persistence\Values\AbstractSession as SessionData;
use Ibexa\Contracts\Collaboration\Session\SessionInterface;

/**
 * @template-implements \Ibexa\Collaboration\Mapper\Domain\SessionDomainMapperInterface<
 *     \App\Collaboration\Cart\Persistence\Values\CartSession
 * >
 */
final class CartSessionDomainMapper implements SessionDomainMapperInterface
{
    public function __construct(
        private CartProxyMapperInterface $cartProxyMapper,
        private UserProxyDomainMapperInterface $userDomainMapper,
        private ParticipantCollectionDomainMapperInterface $participantCollectionDomainMapper
    ) {
    }

    /**
     * @param \App\Collaboration\Cart\Persistence\Values\CartSession $data
     */
    public function fromPersistence(SessionData $data): SessionInterface
    {
        return new CartSession(
            $data->getId(),
            $this->cartProxyMapper->createCartProxy($data->getCartIdentifier()),
            $data->getToken(),
            $this->userDomainMapper->createUserProxy($data->getOwnerId()),
            $this->participantCollectionDomainMapper->createParticipantCollectionProxy($data->getId()),
            $data->isActive(),
            $data->hasPublicLink(),
            $data->getCreatedAt(),
            $data->getUpdatedAt(),
        );
    }
}
  • CartSessionPersistenceMapper - prepares session data to be saved or updated in the database.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Collaboration\Cart\Mapper;

use App\Collaboration\Cart\Persistence\Values\CartSessionCreateStruct;
use App\Collaboration\Cart\Persistence\Values\CartSessionUpdateStruct;
use Ibexa\Collaboration\Mapper\Persistence\SessionPersistenceMapperInterface;
use Ibexa\Collaboration\Persistence\Values\AbstractSessionCreateStruct as PersistenceSessionCreateStruct;
use Ibexa\Collaboration\Persistence\Values\AbstractSessionUpdateStruct as PersistenceSessionUpdateStruct;
use Ibexa\Contracts\Collaboration\Session\AbstractSessionCreateStruct as SessionCreateStruct;
use Ibexa\Contracts\Collaboration\Session\AbstractSessionUpdateStruct as SessionUpdateStruct;
use Ibexa\Contracts\Collaboration\Session\SessionInterface;

final class CartSessionPersistenceMapper implements SessionPersistenceMapperInterface
{
    /**
     * @param \App\Collaboration\Cart\CartSessionCreateStruct $createStruct
     */
    public function toPersistenceCreateStruct(
        SessionCreateStruct $createStruct
    ): PersistenceSessionCreateStruct {
        return new CartSessionCreateStruct(
            $createStruct->getToken(),
            $createStruct->getCart()->getIdentifier(),
            $createStruct->getOwner()->getUserId(),
            $createStruct->isActive(),
            $createStruct->hasPublicLink(),
            new \DateTimeImmutable(),
            new \DateTimeImmutable()
        );
    }

    public function toPersistenceUpdateStruct(
        SessionInterface $session,
        SessionUpdateStruct $updateStruct
    ): PersistenceSessionUpdateStruct {
        return new CartSessionUpdateStruct(
            $session->getId(),
            $updateStruct->getToken(),
            ($updateStruct->getOwner() ?? $session->getOwner())->getUserId()
        );
    }
}

Build dedicated controllers to manage the Cart sharing flow

To support Cart sharing, you need to create controllers which handle the collaboration flow. They are responsible for starting a sharing session, adding participants, and allowing users to join an existing shared Cart.

You need to create two controllers:

  • ShareCartCreateController - to create the Cart collaboration session and add participants
  • ShareCartJoinController that enables joining the session.

ShareCartCreateController

When you enter the user email address and submit it, the request is handled by this controller. It captures the email address and checks whether the form has been submitted. If yes, the form data is retrieved, and the cartResolver verifies whether there is currently a shared Cart.

If a shared Cart exists, the Cart is retrieved and a session is created ($cart becomes the session context). In the next step, addParticipant, the user whose email address was provided is added to the session and assigned a scope (either view or edit).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Controller;

use App\Collaboration\Cart\CartSessionCreateStruct;
use App\Collaboration\Cart\CartSessionType;
use App\Form\Type\ShareCartType;
use Ibexa\Contracts\Cart\CartResolverInterface;
use Ibexa\Contracts\Collaboration\Participant\ExternalParticipantCreateStruct;
use Ibexa\Contracts\Collaboration\SessionServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;

#[AsController]
#[Route('/shared-cart/create', name: 'app.shared_cart.create')]
final class ShareCartCreateController extends AbstractController
{
    public function __construct(
        private SessionServiceInterface $sessionService,
        private CartResolverInterface $cartResolver
    ) {
    }

    public function __invoke(Request $request): Response
    {
        $form = $this->createForm(
            ShareCartType::class,
            null,
            [
                'method' => 'POST',
            ]
        );

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            /** @var \App\Form\Data\ShareCartData $data */
            $data = $form->getData();

            // Handle the form submission
            $cart = $this->cartResolver->resolveCart();

            $session = $this->sessionService->createSession(
                new CartSessionCreateStruct($cart)
            );

            $this->sessionService->addParticipant(
                $session,
                new ExternalParticipantCreateStruct(
                    $data->getEmail(),
                    CartSessionType::SCOPE_EDIT
                )
            );

            return $this->render(
                '@ibexadesign/cart/share_result.html.twig',
                [
                    'session' => $session,
                ]
            );
        }

        return $this->render(
            '@ibexadesign/cart/share.html.twig',
            [
                'form' => $form->createView(),
            ]
        );
    }
}

ShareCartJoinController

It enables joining a Cart session. The session token created earlier is passed in the URL, and in the Join action the system attempts to retrieve the session associated with that token. If the token is invalid, an exception is thrown indicating that the session cannot be accessed. If the session exists, the session parameter (current_collaboration_session) is retrieved and store the token. Finally, redirectToRoute redirects the user to the Cart view and passes the identifier of the shared Cart.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Controller;

use App\Collaboration\Cart\CartSession;
use Ibexa\Contracts\Collaboration\SessionServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;

#[AsController]
#[Route('/shared-cart/join/{token}', name: 'app.shared_cart.join')]
final class ShareCartJoinController extends AbstractController
{
    public const CURRENT_COLLABORATION_SESSION = 'collaboration_session';

    public function __construct(
        private SessionServiceInterface $sessionService,
    ) {
    }

    public function __invoke(Request $request, string $token): RedirectResponse
    {
        $session = $this->sessionService->getSessionByToken($token);
        if ($session instanceof CartSession) {
            $request->getSession()->set(self::CURRENT_COLLABORATION_SESSION, $session->getToken());

            return $this->redirectToRoute('ibexa.cart.view', [
                'identifier' => $session->getCart()->getIdentifier(),
            ]);
        }

        throw $this->createAccessDeniedException();
    }
}

Session parameter

Avoid using a generic session parameter name such as current_collaboration_session (it's used here only for example purposes). If multiple collaboration session types exist, for example, Content and Cart sessions, the parameter may be overwritten when another session is started. Try to use more specific and unique parameter name to prevent conflicts between different session types.

Integrate with Symfony forms by adding forms and templates

To support inviting users to a shared Cart, you need to create a dedicated form and a data class. The form collects the email address of the user that you want to invite, and the data class is used to safely pass that information from the form to the controller.

  • ShareCartType - a simple form for entering the email address of the user you want to invite to share the Cart. The form contains a single input field where you enter the email address manually.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Form\Type;

use App\Form\Data\ShareCartData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ShareCartType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('email', EmailType::class, [
            'label' => 'E-mail',
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ShareCartData::class,
        ]);
    }
}
  • ShareCartData - this class holds the email address submitted through the form and pass it to the controller.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Form\Data;

final class ShareCartData
{
    public function __construct(
        private ?string $email = null
    ) {
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(?string $email): void
    {
        $this->email = $email;
    }
}

The last step is to integrate the new session type into your application by adding templates. In this step, the view is rendered.

You need to add following templates in the templates/themes/standard/cart folder:

  • share - this Twig template defines the view for the Cart sharing form. It renders the form where a user can enter an email address to invite someone to collaborate on the Cart.
1
2
3
4
5
6
7
8
{% extends '@ibexadesign/storefront/layout.html.twig' %}

{% block content %}
    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button class="ibexa-store-btn ibexa-store-btn--primary" type="submit">Share</button>
    {{ form_end(form) }}
{% endblock %}
  • share_result - this Twig template renders the result page after a Cart has been shared. If the shared Cart exists in the system, the created session object is passed to the view and displayed. A message like "Cart has been shared…" is displayed, along with a link to access the session.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% extends '@ibexadesign/storefront/layout.html.twig' %}

{% block content %}
    <p class="ibexa-store-notice-card ibexa-store-notice-card--success">
        Cart has been shared successfully! Link to session: &nbsp;
        <a href="{{ path('app.shared_cart.join', { token: session.getToken() }) }}">
            {{ url('app.shared_cart.join', { token: session.getToken() }) }}
        </a>
    </p>
{% endblock %}
  • view - is the template that shows the Cart page. It displays the Cart content and includes the “Share Cart” button and other elements for Cart collaboration.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
{% extends '@ibexadesign/storefront/layout.html.twig' %}

{% trans_default_domain 'ibexa_storefront' %}

{% macro render_notice_messages(messages) %}
    {% for message in messages -%}
        {{ message }}{{ not loop.last ? '<br/>' }}
    {%- endfor %}
{% endmacro %}

{% block content %}
    <div style="text-align: right">
        <a href="{{ path('app.shared_cart.create') }}" class="ibexa-store-btn ibexa-store-btn--secondary">Share cart</a>
    </div>

    {% set quick_order_errors = app.flashes('quick_order_errors') %}
    {% set quick_order_successes = app.flashes('quick_order_successes') %}
    {% set cart_products_with_errors_codes = app.flashes('cart_products_with_errors_codes') %}

    {% set reorder_errors = app.flashes('reorder_errors') %}
    {% set reorder_errors_products_codes = app.flashes('reorder_errors_products_codes') %}

    {% set products_to_highlight_codes = cart_products_with_errors_codes|merge(reorder_errors_products_codes) %}

    {% if quick_order_errors|length %}
        {% include '@ibexadesign/storefront/component/notice_card/notice_card.html.twig' with {
            type: 'warning',
            content: _self.render_notice_messages(quick_order_errors),
        } %}
    {% endif %}

    {% if quick_order_successes|length %}
        {% include '@ibexadesign/storefront/component/notice_card/notice_card.html.twig' with {
            type: 'success',
            content: _self.render_notice_messages(quick_order_successes),
        } %}
    {% endif %}

    {% if reorder_errors|length %}
        {% include '@ibexadesign/storefront/component/notice_card/notice_card.html.twig' with {
            type: 'warning',
            content:  _self.render_notice_messages(reorder_errors),
        } %}
    {% endif %}

    {% include '@ibexadesign/cart/component/maincart/maincart.html.twig' with {
        products_to_highlight_codes,
        checkout_identifier: app.request.get('checkout_identifier'),
        checkout_step: app.request.get('checkout_step'),
    } %}
{% endblock %}

{% block javascripts %}
    {{ parent() }}

    {{ encore_entry_script_tags('ibexa-storefront-notice-card-js', null, 'storefront') }}
{% endblock %}