Skip to main content
Version: Thelia 3

Data Access Service

DataAccessService is how front-office templates fetch data in Thelia 3. It calls API endpoints internally, without the cost of a real HTTP request.

Overview

Instead of using loops (which are now reserved for back-office), front-office templates use the resources() function to fetch data through the API layer:

{# Fetch a single product #}
{% set product = resources('/api/front/products/' ~ productId) %}

{# Fetch a collection #}
{% set products = resources('/api/front/products', {visible: true}) %}

Twig functions

DataAccessExtension provides these functions:

FunctionDescription
resources(path, params)Fetch data from an API endpoint
attr(type, name)Read a contextual attribute (current product, cart, customer, etc.)
Return type

resources() returns object|array|null. A collection endpoint returns an array of items, a single-item endpoint returns one object, and a missing resource returns null. Do not assume the result is always an array.

The resources() function

Basic usage

{# Fetch a single item by ID #}
{% set product = resources('/api/front/products/123') %}
{{ product.i18ns.title }}

{# Fetch a collection #}
{% set categories = resources('/api/front/categories') %}
{% for category in categories %}
{{ category.i18ns.title }}
{% endfor %}

With parameters

{# Filter by category #}
{% set products = resources('/api/front/products', {
'productCategories.category.id': categoryId,
'visible': true
}) %}

{# Order results #}
{% set categories = resources('/api/front/categories', {
'parent': categoryId,
'order[position]': 'asc',
'visible': true
}) %}

{# Pagination #}
{% set products = resources('/api/front/products', {
'itemsPerPage': 30,
'page': 2
}) %}

Pagination

Use itemsPerPage and page parameters for pagination:

{% set products = resources('/api/front/products', {
'productCategories.category.id': categoryId,
'itemsPerPage': 30,
'page': currentPage
}) %}

{% for product in products %}
{{ product.i18ns.title }}
{% endfor %}
note

In PHP (services, LiveComponents), you can use the 'jsonld' format as a third parameter to DataAccessService::resources() to get Hydra metadata with pagination info. This format parameter is not available in the Twig function.

The attr() function

The attr() function reads a contextual attribute. It calls the matching attribute<Type>() method on AttributeAccessService (so attr('product', 'id') calls attributeProduct('id')). The type comes from the current route attributes (product_id, category_id, etc.), the session (cart, currency, lang), or the configuration table.

{# Get product ID from URL #}
{% set productId = attr('product', 'id') %}

{# Get category ID #}
{% set categoryId = attr('category', 'id') %}

{# Get content ID #}
{% set contentId = attr('content', 'id') %}

{# Get folder ID #}
{% set folderId = attr('folder', 'id') %}

Available attribute types

Each type resolves through a dedicated method on AttributeAccessService.

Type (attr('<type>', ...))MethodResolvable names
productattributeProductAny Propel getter (id, ref, visible, ...) plus i18n columns title, chapo, description, postscriptum
categoryattributeCategorySame as product (getter + i18n columns); falls back to the product's default category
contentattributeContentGetter + i18n columns
folderattributeFolderGetter + i18n columns; falls back to the content's default folder
brandattributeBrandGetter + i18n columns; falls back to the product's brand
currencyattributeCurrencyGetter + i18n column name (from the session currency)
countryattributeCountryOnly default (returns the i18n attributes of the default country)
langattributeLangAny Lang getter (id, title, locale, code, ...) of the session language
configattributeConfigA configuration variable name (ConfigQuery::read())
cartattributeCartSee the cart attributes below
couponattributeCouponhas_coupons, coupon_count, coupon_list, is_delivery_free
customerattributeCustomerAny Customer getter (id, firstname, lastname, email, ...) of the logged-in customer
caution

There is no attr('order', ...). Order attributes are exposed through the orderDataAccess() method, which does not follow the attribute<Type> naming the attr() function relies on. Read order data with resources('/api/front/account/orders/...') instead.

Cart attributes

The cart type resolves a fixed set of names (it does not call Propel getters directly):

{% set itemCount   = attr('cart', 'item_count') %}
{% set productCount = attr('cart', 'product_count') %}
{% set total = attr('cart', 'total_price') %}
{% set deliveryId = attr('cart', 'delivery_module_id') %}
{% set paymentId = attr('cart', 'payment_module_id') %}

Supported cart names: product_count (alias count_product), item_count (alias count_item), postage, taxed_postage, total_price (alias total_price_with_discount), total_price_without_discount, total_price_without_postage, raw_total_price, total_taxed_price (alias total_taxed_price_with_discount), total_taxed_price_without_discount, total_taxed_price_without_postage, raw_taxed_total_price, is_virtual (alias contains_virtual_product), total_vat (alias total_tax_amount), total_tax_amount_without_discount, raw_total_tax_amount, taxed_discount, discount, discount_tax_amount, weight, delivery_module_id, payment_module_id.

Complete example

{# product.html.twig #}
{% extends 'base.html.twig' %}

{% set productId = attr('product', 'id') %}
{% set product = resources('/api/front/products/' ~ productId) %}

{% block body %}
<h1>{{ product.i18ns.title }}</h1>
<p>{{ product.i18ns.description|raw }}</p>
{% endblock %}

Available endpoints

Products

{# Single product #}
{% set product = resources('/api/front/products/' ~ id) %}

{# Products list #}
{% set products = resources('/api/front/products', {
'visible': true,
'productCategories.category.id': categoryId
}) %}

{# Product images #}
{% set images = resources('/api/front/product_images', {
'product.id': productId
}) %}

{# Product sale elements (variants) #}
{% set pses = resources('/api/front/product_sale_elements', {
'product.id': productId
}) %}

{# PSE by reference #}
{% set pse = resources('/api/front/product_sale_elements', {
'ref': 'SKU-123'
})|first %}

Categories

{# Single category #}
{% set category = resources('/api/front/categories/' ~ id) %}

{# Child categories #}
{% set children = resources('/api/front/categories', {
'parent': parentId,
'visible': true,
'order[position]': 'asc'
}) %}

{# Root categories #}
{% set roots = resources('/api/front/categories', {
'parent': 0,
'visible': true
}) %}

Content & Folders

{# Single content #}
{% set content = resources('/api/front/contents/' ~ id) %}

{# Contents in folder #}
{% set contents = resources('/api/front/contents', {
'contentFolders.folder.id': folderId,
'visible': true
}) %}

{# Folder #}
{% set folder = resources('/api/front/folders/' ~ id) %}

Customers

The current customer ID is read with attr('customer', 'id'), then passed to the account endpoint (requires ROLE_CUSTOMER):

{# Logged-in customer #}
{% set customerId = attr('customer', 'id') %}
{% set customer = resources('/api/front/account/customers/' ~ customerId) %}

{# Customer addresses #}
{% set addresses = resources('/api/front/account/addresses', {
'customer.id': customerId
}) %}

Cart & Orders

{# Current session cart #}
{% set cart = resources('/api/front/cart') %}

{# Customer orders #}
{% set orders = resources('/api/front/account/orders') %}

{# Single order #}
{% set order = resources('/api/front/account/orders/' ~ orderId) %}
note

The current cart endpoint is the singular /api/front/cart (served by a dedicated controller that resolves the session cart). The plural /api/front/carts/{id} and /api/front/account/orders/{id} endpoints are scoped to the authenticated customer.

Using it in PHP (services and LiveComponents)

Injecting DataAccessService

<?php

declare(strict_types=1);

namespace App\Service;

use Thelia\Api\Service\DataAccess\DataAccessService;

final readonly class ProductService
{
public function __construct(
private DataAccessService $dataAccessService,
) {}

public function getVisibleProducts(int $limit = 10): object|array|null
{
return $this->dataAccessService->resources('/api/front/products', [
'visible' => true,
'order[position]' => 'asc',
'itemsPerPage' => $limit,
]);
}

public function getProduct(int $id): object|array|null
{
return $this->dataAccessService->resources('/api/front/products/' . $id);
}
}

In LiveComponents

<?php

declare(strict_types=1);

namespace FlexyBundle\UiComponents\CategoryFilters;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Thelia\Api\Service\DataAccess\DataAccessService;

#[AsLiveComponent(name: 'Flexy:CategoryFilters')]
class CategoryFilters
{
use DefaultActionTrait;

#[LiveProp]
public ?int $categoryId = null;

#[LiveProp]
public int $page = 1;

public ?array $products = [];

public function __construct(
private readonly DataAccessService $dataAccessService,
) {}

public function mount(?int $initialCategoryId, ?int $initialPage): void
{
$this->categoryId = $initialCategoryId;
$this->page = $initialPage ?? 1;

$this->loadProducts();
}

private function loadProducts(): void
{
// Default format (JSON) - returns simple array
$this->products = $this->dataAccessService->resources('/api/front/products', [
'productCategories.category.id' => $this->categoryId,
'itemsPerPage' => 30,
'page' => $this->page,
]);
}
}

Using the JSON-LD format for pagination metadata

When you need pagination metadata (total items, next and previous page links), pass the 'jsonld' format:

private function loadProductsWithPagination(): void
{
$response = $this->dataAccessService->resources('/api/front/products', [
'productCategories.category.id' => $this->categoryId,
'itemsPerPage' => 30,
'page' => $this->page,
], 'jsonld'); // Third parameter enables JSON-LD format

$this->products = $response['hydra:member'];
$this->totalItems = $response['hydra:totalItems'];
// Also available: hydra:view with hydra:first, hydra:last, hydra:next, hydra:previous
}
Default vs JSON-LD format

With no format (the default), you get a plain array of items, which covers most cases. The 'jsonld' format instead returns Hydra metadata (hydra:member, hydra:totalItems, hydra:view); use it when you need pagination info or a total count.

Filtering parameters

Common filters

ParameterDescriptionExample
visibleFilter by visibility'visible': true
order[field]Sort by field'order[position]': 'asc'
itemsPerPagePagination limit'itemsPerPage': 30
pagePage number'page': 2

Relation filters

{# Filter by related entity ID #}
{% set products = resources('/api/front/products', {
'productCategories.category.id': categoryId
}) %}

{# Filter by brand #}
{% set products = resources('/api/front/products', {
'brand.id': brandId
}) %}

Search filters

The product title filter uses a word_start strategy: it matches the beginning of each word in the translated title.

{# Text search on the product title #}
{% set products = resources('/api/front/products', {
'title': searchQuery
}) %}

Custom filters (tfilters)

Thelia has a tfilters system for faceted navigation. The /api/front/tfilters/{resource} endpoint returns the filters available for a resource (for example products), and you then apply the same tfilters payload to the resource collection. The CategoryFilters LiveComponent follows this pattern.

{# Get available product filters for a category #}
{% set filters = resources('/api/front/tfilters/products', {
'tfilters[category]': categoryId
}) %}

{# Apply the selected filters to the product collection #}
{% set products = resources('/api/front/products', {
'productCategories.category.id': categoryId,
'tfilters': selectedFilters
}) %}

Accessing translated content

API responses include translated content in the i18ns property:

{% set product = resources('/api/front/products/' ~ productId) %}

{# Access translations #}
{{ product.i18ns.title }}
{{ product.i18ns.description|raw }}
{{ product.i18ns.chapo }}
{{ product.i18ns.postscriptum }}

{# With default fallback #}
{{ product.i18ns.chapo|default('No description available') }}

Error handling

{% set product = resources('/api/front/products/' ~ productId) %}

{% if product %}
<h1>{{ product.i18ns.title }}</h1>
{% else %}
<p>Product not found</p>
{% endif %}

In PHP:

// resources() returns object|array|null
$product = $this->dataAccessService->resources('/api/front/products/' . $id);

if ($product === null) {
throw new NotFoundHttpException('Product not found');
}

Performance

Avoid N+1 queries

{# Bad - multiple API calls in loop #}
{% for product in products %}
{% set images = resources('/api/front/product_images', {'product.id': product.id}) %}
{% endfor %}

{# Good - fetch all at once if possible, or use component #}
{{ component('Flexy:ProductCard', {product: product}) }}

Use pagination

{# Always limit results #}
{% set products = resources('/api/front/products', {
'itemsPerPage': 30,
'page': page
}) %}

Caching

DataAccessService calls go through API Platform's caching layer, so responses can be served from the HTTP cache once it is configured.

Next steps