Skip to main content
Version: Thelia 3

Resource Addons

Addons allow modules to enrich existing API resources with additional data without modifying the core resource classes.

When to use addons​

ScenarioUse
New entity with its own CRUDResource
Add fields to Product, Customer, Order...Addon
Computed data (stock, discounts)Addon
Related data from another tableAddon

ResourceAddonInterface​

interface ResourceAddonInterface
{
// Parent resource this addon enriches
public static function getResourceParent(): string;

// Related Propel table (if any)
public static function getPropelRelatedTableMap(): ?TableMap;

// Extend the query (add joins, virtual columns)
public static function extendQuery(ModelCriteria $query, ?Operation $operation = null, array $context = []): void;

// Build addon from Propel model
public function buildFromModel(ActiveRecordInterface $activeRecord, PropelResourceInterface $resource): self;

// Build addon from array (for write operations)
public function buildFromArray(array $data, PropelResourceInterface $resource): self;

// Persist addon data
public function doSave(ActiveRecordInterface $activeRecord, PropelResourceInterface $resource): void;

// Delete addon data
public function doDelete(ActiveRecordInterface $activeRecord, PropelResourceInterface $resource): void;

// Context management (set by the framework before build)
public function setContext(array $context = []): self;
public function getContext(): array;
}

Creating an addon​

Example: adding a customer family to a customer​

<?php

declare(strict_types=1);

namespace CustomerFamily\Api\Resource;

use Propel\Runtime\ActiveRecord\ActiveRecordInterface;
use Propel\Runtime\Map\TableMap;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\Ignore;
use Thelia\Api\Resource\Customer as CustomerResource;
use Thelia\Api\Resource\Order as OrderResource;
use Thelia\Api\Resource\PropelResourceInterface;
use Thelia\Api\Resource\ResourceAddonInterface;
use Thelia\Api\Resource\ResourceAddonTrait;
use CustomerFamily\Model\CustomerCustomerFamilyQuery;
use CustomerFamily\Model\CustomerFamilyQuery;
use CustomerFamily\Model\Map\CustomerCustomerFamilyTableMap;

class CustomerCustomerFamily implements ResourceAddonInterface
{
use ResourceAddonTrait;

public ?int $id = null;

#[Groups([
CustomerResource::GROUP_ADMIN_READ,
CustomerResource::GROUP_ADMIN_WRITE,
CustomerResource::GROUP_FRONT_READ_SINGLE,
OrderResource::GROUP_ADMIN_READ,
])]
public ?string $code = null;

#[Ignore]
public static function getResourceParent(): string
{
return CustomerResource::class;
}

#[Ignore]
public static function getPropelRelatedTableMap(): ?TableMap
{
return new CustomerCustomerFamilyTableMap();
}

public function buildFromModel(
ActiveRecordInterface $activeRecord,
PropelResourceInterface $resource
): ResourceAddonInterface {
$customerFamily = CustomerCustomerFamilyQuery::create()
->filterByCustomerId($activeRecord->getId())
->findOne();

if (null === $customerFamily) {
return $this;
}

$this->setCode($customerFamily->getCustomerFamily()?->getCode());

return $this;
}

public function buildFromArray(
array $data,
PropelResourceInterface $resource
): ResourceAddonInterface {
$this->setCode($data['code'] ?? null);

return $this;
}

public function doSave(
ActiveRecordInterface $activeRecord,
PropelResourceInterface $resource
): void {
if (null === $this->code) {
return;
}

$customerFamily = CustomerFamilyQuery::create()
->findOneByCode($this->code);

if (null === $customerFamily) {
return;
}

$model = CustomerCustomerFamilyQuery::create()
->filterByCustomerId($activeRecord->getId())
->findOneOrCreate();

$model->setCustomerId($activeRecord->getId());
$model->setCustomerFamilyId($customerFamily->getId());
$model->save();
}

public function doDelete(
ActiveRecordInterface $activeRecord,
PropelResourceInterface $resource
): void {
CustomerCustomerFamilyQuery::create()
->filterByCustomerId($activeRecord->getId())
->delete();
}

// Getters and setters
public function getCode(): ?string
{
return $this->code;
}

public function setCode(?string $code): self
{
$this->code = $code;
return $this;
}
}

ResourceAddonTrait​

The trait provides default implementations for common patterns:

use Thelia\Api\Resource\ResourceAddonTrait;

class MyAddon implements ResourceAddonInterface
{
use ResourceAddonTrait;
// ...
}

Default behavior​

The trait uses Propel virtual columns for automatic mapping:

  1. extendQuery() - Adds LEFT JOIN and virtual columns based on getPropelRelatedTableMap()
  2. buildFromModel() - Maps virtual columns to addon properties
  3. doSave() / doDelete() - Uses reflection to persist via Propel relations

Override these methods when you need custom logic.

Static methods​

Static methods like getResourceParent() and getPropelRelatedTableMap() are not introspected by the serializer, so they do not need #[Ignore]:

public static function getResourceParent(): string
{
return Customer::class;
}

public static function getPropelRelatedTableMap(): ?TableMap
{
return new MyAddonTableMap();
}

Groups​

Use the parent resource's groups for addon properties:

#[Groups([
CustomerResource::GROUP_ADMIN_READ, // From Customer
CustomerResource::GROUP_ADMIN_WRITE, // From Customer
])]
public ?string $customField = null;

Extending queries​

For complex data retrieval, override extendQuery():

public static function extendQuery(
ModelCriteria $query,
?Operation $operation = null,
array $context = []
): void {
$query
->leftJoinWith('CustomerStock')
->withColumn('CustomerStock.Quantity', 'stockQuantity');
}

Computed data (no database)​

For computed properties without a database table, return null from getPropelRelatedTableMap() and override extendQuery() (the trait's default throws an exception when the table map is null):

class ProductStockAddon implements ResourceAddonInterface
{
use ResourceAddonTrait;

#[Groups([Product::GROUP_ADMIN_READ, Product::GROUP_FRONT_READ])]
public ?int $totalStock = null;

public static function getResourceParent(): string
{
return Product::class;
}

public static function getPropelRelatedTableMap(): ?TableMap
{
return null; // No related table
}

public static function extendQuery(
ModelCriteria $query,
?Operation $operation = null,
array $context = []
): void {
// Nothing to join - computed from buildFromModel
}

public function buildFromModel(
ActiveRecordInterface $activeRecord,
PropelResourceInterface $resource
): ResourceAddonInterface {
// Calculate total stock from all PSEs
$totalStock = ProductSaleElementsQuery::create()
->filterByProductId($activeRecord->getId())
->withColumn('SUM(quantity)', 'total')
->findOne();

$this->totalStock = (int) $totalStock?->getVirtualColumn('total');

return $this;
}

public function buildFromArray(array $data, PropelResourceInterface $resource): self
{
return $this; // Read-only
}

public function doSave(ActiveRecordInterface $activeRecord, PropelResourceInterface $resource): void
{
// Nothing to save
}

public function doDelete(ActiveRecordInterface $activeRecord, PropelResourceInterface $resource): void
{
// Nothing to delete
}
}

Auto-discovery​

Addons are auto-discovered from Api/Resource/ inside any activated module's directory. Discovery is driven by the database: only modules registered and activated via ModuleQuery::getActivated() are scanned.

They are registered via the thelia.api.resource.addon tag.

API response​

Addon fields appear at the root level of the resource:

{
"id": 1,
"email": "customer@example.com",
"firstname": "John",
"lastname": "Doe",
"code": "FAMILY_A" // From CustomerCustomerFamily addon
}

Multiple addons​

A resource can have multiple addons. Each addon's properties are merged into the response:

{
"id": 1,
"ref": "PROD-001",
"title": "Product",
"code": "FAMILY_A", // From addon 1
"totalStock": 42, // From addon 2
"customField": "value" // From addon 3
}

Best practices​

  1. Use parent's groups - Addon properties should use the parent resource's groups
  2. Mark static methods with #[Ignore] - Prevents serialization errors
  3. Handle null gracefully - Related data may not exist
  4. Keep addons focused - One addon per concern
  5. Override only what you need - Use trait defaults when possible

Next steps​