Skip to main content
Version: Thelia 3

Controllers

Controllers handle HTTP requests in your module. Thelia provides two base controller types, one for the front office and one for the back office.

Controller types

TypeBase ClassTemplate engineAuthorization
FrontBaseFrontControllerActive front-office template (Flexy / Twig)Manual: call checkAuth() when a page requires a logged-in customer
AdminBaseAdminControllerActive back-office templateManual: call checkAuth($resources, $modules, $accesses) per action
The back-office reference is the default-twig bundle

Admin controllers render through the active back-office template, resolved by TheliaTemplateHelper (via getActiveAdminTemplate()). The reference back-office template in Thelia 3 is the default-twig bundle (Twig). The legacy Smarty default back-office theme is no longer recommended and is expected to be dropped in Thelia 3.1, so target Twig templates for new modules.

Authorization is not automatic

Extending BaseAdminController does not secure your routes by itself. You must call checkAuth() at the start of each action that needs protection (see Authorization Checks).

Front controllers

Front controllers render public-facing pages with Twig templates.

Basic front controller

<?php

declare(strict_types=1);

namespace MyProject\Controller\Front;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Controller\Front\BaseFrontController;

final class PageController extends BaseFrontController
{
#[Route('/my-feature', name: 'myproject.front.index')]
public function indexAction(): Response
{
return $this->render('my-page');
}

#[Route('/my-feature/{id}', name: 'myproject.front.show', requirements: ['id' => '\d+'])]
public function showAction(int $id): Response
{
return $this->render('my-page-detail', [
'item_id' => $id,
]);
}
}

Template location

Templates are resolved from:

  1. templates/frontOffice/{active_template}/modules/MyProject/
  2. local/modules/MyProject/templates/frontOffice/default/

Injecting services

<?php

declare(strict_types=1);

namespace MyProject\Controller\Front;

use MyProject\Service\ProductService;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Controller\Front\BaseFrontController;

final class ProductController extends BaseFrontController
{
public function __construct(
private readonly ProductService $productService,
) {}

#[Route('/featured-products', name: 'myproject.front.featured')]
public function featuredAction(): Response
{
$products = $this->productService->getFeaturedProducts();

return $this->render('featured-products', [
'products' => $products,
]);
}
}

Accessing request data

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

#[Route('/search', name: 'myproject.front.search')]
public function searchAction(Request $request): Response
{
$query = $request->query->get('q', '');
$page = $request->query->getInt('page', 1);

$results = $this->searchService->search($query, $page);

return $this->render('search-results', [
'query' => $query,
'results' => $results,
'page' => $page,
]);
}

JSON responses

use Symfony\Component\HttpFoundation\JsonResponse;

#[Route('/api/check-availability/{productId}', name: 'myproject.front.check_availability')]
public function checkAvailabilityAction(int $productId): JsonResponse
{
$stock = $this->stockService->getAvailableStock($productId);

return new JsonResponse([
'available' => $stock > 0,
'quantity' => $stock,
]);
}

Requiring a logged-in customer

On the front office, checkAuth() takes no arguments. It throws a RedirectException to the login page when no customer is authenticated:

// core/lib/Thelia/Controller/Front/BaseFrontController.php
public function checkAuth(): void

Call it at the start of an action that must only be reachable by a logged-in customer:

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Controller\Front\BaseFrontController;

final class AccountController extends BaseFrontController
{
#[Route('/my-account/orders', name: 'myproject.front.account_orders')]
public function ordersAction(): Response
{
// Redirects to the login page if no customer is logged in
$this->checkAuth();

$customer = $this->getSecurityContext()->getCustomerUser();

return $this->render('account-orders', [
'customer' => $customer,
]);
}
}
Front vs admin signature

The argument-based form checkAuth($resources, $modules, $accesses) exists only on BaseAdminController. On BaseFrontController, checkAuth() is argument-less. Do not pass AccessManager constants to a front controller's checkAuth().

Redirects

use Symfony\Component\HttpFoundation\RedirectResponse;

#[Route('/old-page', name: 'myproject.front.old_page')]
public function oldPageAction(): RedirectResponse
{
// getRoute() turns a route id into a URL string, generateRedirect() wraps it in a RedirectResponse
return $this->generateRedirect(
$this->getRoute('myproject.front.new_page')
);
}
Redirect to a route in one call

generateRedirectFromRoute() combines both steps. Pass URL parameters as the second argument:

return $this->generateRedirectFromRoute(
'myproject.front.show',
[], // extra query parameters appended to the URL
['id' => 42], // route parameters (placeholders)
);

Admin controllers

Admin controllers render back-office pages through the active back-office template (the default-twig bundle). They run behind the /admin firewall, but fine-grained permission checks are not automatic: call checkAuth() in each action that modifies data or exposes restricted resources.

Basic admin controller

<?php

declare(strict_types=1);

namespace MyProject\Controller\Admin;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Controller\Admin\BaseAdminController;

final class ConfigController extends BaseAdminController
{
#[Route('/admin/module/MyProject', name: 'myproject.admin.config')]
public function indexAction(): Response
{
return $this->render('module-config');
}
}

Template location

Admin templates are resolved from:

  1. templates/backOffice/{active_template}/modules/MyProject/
  2. local/modules/MyProject/templates/backOffice/default/

Form handling

<?php

declare(strict_types=1);

namespace MyProject\Controller\Admin;

use MyProject\Form\ConfigurationForm;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Controller\Admin\BaseAdminController;
use Thelia\Core\Security\AccessManager;
use Thelia\Model\ConfigQuery;

final class ConfigController extends BaseAdminController
{
#[Route('/admin/module/MyProject', name: 'myproject.admin.config', methods: ['GET'])]
public function indexAction(): Response
{
return $this->render('module-config', [
'api_key' => ConfigQuery::read('myproject_api_key', ''),
'enabled' => ConfigQuery::read('myproject_enabled', '1'),
]);
}

#[Route('/admin/module/MyProject', name: 'myproject.admin.config.save', methods: ['POST'])]
public function saveAction(): Response
{
// Check authorization
if (null !== $response = $this->checkAuth([], [], AccessManager::UPDATE)) {
return $response;
}

$form = $this->createForm(ConfigurationForm::getName());

try {
$data = $this->validateForm($form)->getData();

ConfigQuery::write('myproject_api_key', $data['api_key']);
ConfigQuery::write('myproject_enabled', $data['enabled'] ? '1' : '0');

return $this->generateSuccessRedirect($form);
} catch (\Exception $e) {
$this->setupFormErrorContext(
'Configuration',
$e->getMessage(),
$form
);

return $this->render('module-config', [
'api_key' => ConfigQuery::read('myproject_api_key', ''),
'enabled' => ConfigQuery::read('myproject_enabled', '1'),
]);
}
}
}

Authorization checks

Control access to specific actions:

use Thelia\Core\Security\AccessManager;

// Check for specific permission
if (null !== $response = $this->checkAuth([], [], AccessManager::DELETE)) {
return $response; // User not authorized
}

// Check for module-specific permission
if (null !== $response = $this->checkAuth(
['MYPROJECT_ADMIN'], // Resources
['MyProject'], // Modules
AccessManager::UPDATE // Access type
)) {
return $response;
}

Access types:

  • AccessManager::VIEW - Read access
  • AccessManager::CREATE - Create new items
  • AccessManager::UPDATE - Modify existing items
  • AccessManager::DELETE - Delete items

AJAX actions

use Symfony\Component\HttpFoundation\JsonResponse;

#[Route('/admin/module/MyProject/ajax/toggle/{id}', name: 'myproject.admin.toggle')]
public function toggleAction(int $id): JsonResponse
{
if (null !== $response = $this->checkAuth([], [], AccessManager::UPDATE)) {
return new JsonResponse(['error' => 'Unauthorized'], 403);
}

try {
$item = \MyProject\Model\MyProjectDataQuery::create()->findPk($id);

if (!$item) {
return new JsonResponse(['error' => 'Item not found'], 404);
}

$item->setIsActive(!$item->getIsActive());
$item->save();

return new JsonResponse([
'success' => true,
'active' => $item->getIsActive(),
]);
} catch (\Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], 500);
}
}

Flash messages

use Thelia\Core\Translation\Translator;

// Success message
$this->getSession()->getFlashBag()->add(
'success',
Translator::getInstance()->trans('Configuration saved', [], 'myproject')
);

// Error message
$this->getSession()->getFlashBag()->add(
'error',
Translator::getInstance()->trans('An error occurred', [], 'myproject')
);

Route configuration

Declare routes with PHP 8 #[Route] attributes directly on your controller methods. Thelia scans every active module's Controller/ directory at boot through ModuleAttributeLoader and registers the routes automatically. There is no XML to write and nothing to import.

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/my-feature/{id}', name: 'myproject.front.show', requirements: ['id' => '\d+'], methods: ['GET'])]
public function showAction(int $id): Response
{
// ...
}

The #[Route] attributes shown throughout this page are picked up the same way, with no extra registration step.

Route prefix

ModuleAttributeLoader prepends each module's route prefix to every path it discovers. The prefix comes from the module's getRoutePrefix() method (defined on BaseModule). Keep this in mind when you generate URLs or read paths in the route table.

Legacy routing.xml

Older modules declared routes in Config/routing.xml. This still works but is no longer the recommended approach for new code. Prefer #[Route] attributes, which keep the route next to its action and require no XML maintenance.

Useful controller methods

The following methods live on BaseController and are available to both front and admin controllers.

Shared (BaseController)

// Render a template ($args, then optional HTTP status)
$this->render('template-name', ['var' => 'value']);

// Generate a URL string from a route id (ABSOLUTE_URL by default)
$url = $this->getRoute('myproject.front.show', ['id' => 42]);

// Build a RedirectResponse from a URL
$this->generateRedirect($url);

// Build a RedirectResponse directly from a route id
$this->generateRedirectFromRoute('myproject.front.show', [], ['id' => 42]);

// Access the session
$session = $this->getSession();

// Access the security context (current customer or admin)
$securityContext = $this->getSecurityContext();

// Access the current request
$request = $this->getRequest();

// Create and validate a Thelia form
$form = $this->createForm(MyForm::getName());
$validatedForm = $this->validateForm($form);

// Build a RedirectResponse to the form's success_url (null if none defined)
$this->generateSuccessRedirect($form);

Front-specific (BaseFrontController)

// Require a logged-in customer (redirects to login otherwise) - no arguments
$this->checkAuth();

// Get the current customer
$customer = $this->getSecurityContext()->getCustomerUser();

Admin-specific (BaseAdminController)

// Check authorization (arguments: resources, modules, accesses)
// Returns a Response (the error page) when not granted, null when allowed
if (null !== $response = $this->checkAuth($resources, $modules, $accesses)) {
return $response;
}

// Build the form error context (title, message, form)
$this->setupFormErrorContext($action, $errorMessage, $form);

// Get the current admin
$admin = $this->getSecurityContext()->getAdminUser();

Best practices

Do

  • Inject services through the constructor instead of reaching into the container directly
  • Declare routes with #[Route] attributes
  • Validate all input from requests
  • Return the response type that fits the action: Response, JsonResponse, or RedirectResponse
  • Follow the naming conventions: {action}Action for methods, {module}.{context}.{action} for routes

Don't

  • Don't put business logic in controllers; move it into services
  • Don't skip authorization checks in admin controllers
  • Don't return HTML from AJAX endpoints; return JSON
  • Don't hardcode URLs; generate them from route ids