Sylius / Send Confirmation Email VS state machine - symfony

Initial Post:
I use a sylius version 1.0dev.
I need some help about the checkout/complete and email confirmation, the issue seems to be known (https://github.com/Sylius/Sylius/issues/2915), but fixes are not working for me.
At this step, the payment status seems to be paid whereas it should be awaiting_payment.
In addition, the order confirmation email should not be sent, but only after payment on payum gateway.
Is there any existing configuration which triggers this event, and how to implement it please ?
Thanks!
Issue partially solved: I implemented a workaround.
First thing to know: I override shopbundle extending it into my own bundle:
<?php
namespace My\ShopBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MyShopBundle extends Bundle
{
public function getParent()
{
return 'SyliusShopBundle';
}
}
I overrided the sylius.email_manager.order service:
# sylius service configuration
parameters:
# override email manager for order
sylius.myproject.order.email.manager.class:MyBundle\ShopBundle\EmailManager\OrderEmailManager
#################################################
# override service sylius.email_manager.order #
# #
# - use configurable class #
# - send service container in arguments #
# #
#################################################
services:
sylius.email_manager.order:
class: %sylius.myproject.order.email.manager.class%
arguments:
- #sylius.email_sender
- #service_container
Classes look like that:
<?php
namespace My\ShopBundle\EmailManager;
use Sylius\Bundle\CoreBundle\Mailer\Emails;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Mailer\Sender\SenderInterface;
use My\ShopBundle\Mailer\Emails as MyEmails;
/**
* #author I
*/
class OrderEmailManager
{
/**
* #var SenderInterface
*/
protected $emailSender;
/**
*
*/
protected $container ;
/**
* #param SenderInterface $emailSender
*/
public function __construct( SenderInterface $emailSender, $container )
{
$this->emailSender = $emailSender;
$this->container = $container ;
}
/**
* #param OrderInterface $order
* Issue : sent on checkout/complete (payment is not yet done on gateway)
*/
public function sendConfirmationEmail(OrderInterface $order)
{
// sylius.shop.email.bcc is array parameter: expected bcc as array variable
if( $this->container->hasParameter( 'sylius.shop.email.bcc' ) ){
$this->emailSender->send( Emails::ORDER_CONFIRMATION, [$order->getCustomer()->getEmail()], ['order' => $order], $this->container->getParameter( 'sylius.shop.email.bcc' ) ) ;
}else{
// no bcc defined
$this->emailSender->send( Emails::ORDER_CONFIRMATION, [$order->getCustomer()->getEmail()], ['order' => $order] ) ;
}
}
/**
* function used on gateway payment
* here we use MyEmails to define the template (as it is done for the default confimration mail)
*/
public function sendPaymentConfirmationEmail(OrderInterface $order)
{
// sylius.shop.email.bcc is array parameter: expected bcc as array variable
if( $this->container->hasParameter( 'sylius.shop.email.bcc' ) ){
$this->emailSender->send( MyEmails::ORDER_PAID, [$order->getCustomer()->getEmail()], ['order' => $order], $this->container->getParameter( 'sylius.shop.email.bcc' ) ) ;
}else{
// no bcc defined
$this->emailSender->send( MyEmails::ORDER_PAID, [$order->getCustomer()->getEmail()], ['order' => $order] ) ;
}
}
}
/**
*
* (c) I
*
*/
namespace My\ShopBundle\Mailer;
/**
* #author I
*/
class Emails
{
const ORDER_PAID = 'order_paid';
}
Template is located as expected: Resources/views/Email/orderPaid.html.twig and is configured as below:
sylius_mailer:
emails:
order_paid:
subject: "The email subject"
template: "MyShopBundle:Email:orderPaid.html.twig"
To disable default confirmation mailing, configure the state machine:
winzou_state_machine:
# disable order confirmation email on checkout/complete (we prefer on thank you action, see order.yml configuration in MyShopBundle to override the thankYouAction)
sylius_order:
callbacks:
after:
sylius_order_confirmation_email:
disabled: true
To trigger confirmation mailing on thankYou action (use case gateway payment is successfully done), in my bundle (Resources/config/routing/order.yml):
sylius_shop_order_pay:
path: /{lastNewPaymentId}/pay
methods: [GET]
defaults:
_controller: sylius.controller.payum:prepareCaptureAction
_sylius:
redirect:
route: sylius_shop_order_after_pay
sylius_shop_order_after_pay:
path: /after-pay
methods: [GET]
defaults:
_controller: sylius.controller.payum:afterCaptureAction
sylius_shop_order_thank_you:
path: /thank-you
methods: [GET]
defaults:
# OVERRIDE (add: send mail success confirmation + standard controller because this one is not a service and must not be)
_controller: MyShopBundle:Payment:thankYou
_sylius:
template: MyShopBundle:Checkout:thankYou.html.twig
sylius_shop_order_show_details:
path: /{tokenValue}
methods: [GET]
defaults:
_controller: sylius.controller.order:showAction
_sylius:
template: SyliusShopBundle:Checkout:orderDetails.html.twig
grid: sylius_shop_account_order
section: shop_account
repository:
method: findOneBy
arguments:
[tokenValue: $tokenValue]
Finally we override the thankYouAction using a standard symfony controller, as below:
<?php
namespace My\ShopBundle\Controller ;
// use Doctrine\ORM\EntityManager;
// use FOS\RestBundle\View\View;
// use Payum\Core\Registry\RegistryInterface;
// use Sylius\Bundle\ResourceBundle\Controller\RequestConfiguration;
// use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
// use Sylius\Component\Order\Context\CartContextInterface;
// use Sylius\Component\Order\Model\OrderInterface;
// use Sylius\Component\Order\SyliusCartEvents;
// use Sylius\Component\Resource\ResourceActions;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Webmozart\Assert\Assert;
// Carefull using ResourceController extending, cause this is a service and not a controller (different constructor)
class PaymentController extends Controller
{
/**
* #param Request $request
*
* #return Response
*/
public function thankYouAction(Request $request = null)
{
// old implementation (get parameters from custom configuration, more heavy and difficult to maintain)
//$configuration = $this->requestConfigurationFactory->create($this->metadata, $request);
// default value for order
$order = null ;
// if session variable sylius_order_id exist : deal with order
if( $request->getSession()->has( 'sylius_order_id' ) ){
$orderId = $request->getSession()->get( 'sylius_order_id', null ) ;
// old behaviour based on custom configuration in case session variable does not exist anymore: does a homepage redirect
// if (null === $orderId) {
// return $this->redirectHandler->redirectToRoute(
// $configuration,
// $configuration->getParameters()->get('after_failure[route]', 'sylius_shop_homepage', true),
// $configuration->getParameters()->get('after_failure[parameters]', [], true)
// );
// }
$request->getSession()->remove( 'sylius_order_id' ) ;
// prefer call repository service in controller (previously repository came from custom configuration)
$orderRepository = $this->get( 'sylius.repository.order' ) ;
$order = $orderRepository->find( $orderId ) ;
Assert::notNull($order);
// send email confirmation via sylius.email_manager.order service
$this->sendEmailConfirmation( $order ) ;
// old rendering from tankyouAction in Sylius\Bundle\CoreBundle\Controller\OrderController
// $view = View::create()
// ->setData([
// 'order' => $order
// ])
// ->setTemplate($configuration->getParameters()->get('template'))
// ;
// return $this->viewHandler->handle($configuration, $view);
// prefer symfony rendering (controller knows its view, execute a controller creation with command line, your template will be defined inside)
$response = $this->render( 'MyShopBundle:Checkout:thankYou.html.twig', array( 'order' => $order ) ) ;
// deal with http cache expiration duration
$response->setSharedMaxAge( 3600 ) ;
}else{
// redirect to home page
$response = $this->redirect( $this->generateUrl( 'sylius_shop_homepage' ) ) ;
}
return $response ;
}
/**
*
*/
private function sendEmailConfirmation( $order ){
$emailService = $this->container->get( 'sylius.email_manager.order' ) ;
$emailService->sendPaymentConfirmationEmail( $order ) ;
}
}
This is a workaround, issue here is not fully solved, and is about doing same thing using state machine. Default configuration mail seems to be sent on checkout complete whereas it shoud take care about payment status instead.
Thanks !

Related

How can I declare a class properly in Symfony 4?

I am trying to work with dataTables according to this tutorial (https://github.com/webinarium/DataTablesBundle/wiki), but I have some troubles:
src/Controller/DataTableController.php
<?php
namespace App\Controller;
use DataTables\DataTablesInterface;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
/**
*
* #Route("/users", name="users")
*
* #param Request $request
* #param DataTablesInterface $datatables
* #return JsonResponse
*/
class DataTableController extends Controller
{
const ID = 'users';
public function usersAction(Request $request, DataTablesInterface $datatables): JsonResponse
{
try {
// Tell the DataTables service to process the request,
// specifying ID of the required handler.
$results = $datatables->handle($request, 'users');
return $this->json($results);
}
catch (HttpException $e) {
// In fact the line below returns 400 HTTP status code.
// The message contains the error description.
return $this->json($e->getMessage(), $e->getStatusCode());
}
}
}
src/DataTables/UsersDataTable.php
<?php
use DataTables\DataTableHandlerInterface;
use DataTables\DataTableQuery;
use DataTables\DataTableResults;
use Symfony\Bridge\Doctrine\RegistryInterface;
class UsersDataTable implements DataTableHandlerInterface
{
protected $doctrine;
/**
* Dependency Injection constructor.
*
* #param RegistryInterface $doctrine
*/
public function __construct(RegistryInterface $doctrine)
{
$this->doctrine = $doctrine;
}
/**
* {#inheritdoc}
*/
public function handle(DataTableQuery $request): DataTableResults
{
/** #var \Doctrine\ORM\EntityRepository $repository */
$repository = $this->doctrine->getRepository('AppBundle:User');
$results = new DataTableResults();
// Total number of users.
$query = $repository->createQueryBuilder('u')->select('COUNT(u.id)');
$results->recordsTotal = $query->getQuery()->getSingleScalarResult();
// Query to get requested entities.
$query = $repository->createQueryBuilder('u');
// Search.
if ($request->search->value) {
$query->where('(LOWER(u.username) LIKE :search OR' .
' LOWER(u.email) LIKE :search)');
$query->setParameter('search', strtolower("%{$request->search->value}%"));
}
// Filter by columns.
foreach ($request->columns as $column) {
if ($column->search->value) {
$value = strtolower($column->search->value);
// "ID" column
if ($column->data == 0) {
$query->andWhere('u.id = :id');
$query->setParameter('id', intval($value));
}
// "Username" column
elseif ($column->data == 1) {
$query->andWhere('LOWER(u.username) LIKE :username');
$query->setParameter('username', "%{$value}%");
}
// "Email" column
elseif ($column->data == 2) {
$query->andWhere('LOWER(u.email) LIKE :email');
$query->setParameter('email', "%{$value}%");
}
}
}
// Order.
foreach ($request->order as $order) {
// "ID" column
if ($order->column == 0) {
$query->addOrderBy('u.id', $order->dir);
}
// "Username" column
elseif ($order->column == 1) {
$query->addOrderBy('u.username', $order->dir);
}
// "Email" column
elseif ($order->column == 2) {
$query->addOrderBy('u.email', $order->dir);
}
}
// Get filtered count.
$queryCount = clone $query;
$queryCount->select('COUNT(u.id)');
$results->recordsFiltered = $queryCount->getQuery()->getSingleScalarResult();
// Restrict results.
$query->setMaxResults($request->length);
$query->setFirstResult($request->start);
/** #var \AppBundle\Entity\User[] $users */
$users = $query->getQuery()->getResult();
foreach ($users as $user) {
$results->data[] = [
$user->getId(),
$user->getUsername(),
$user->getEmail(),
];
}
return $results;
}
}
config/services.yaml
parameters:
locale: 'en'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
datatable.users:
class: AppBundle\DataTables\UsersDataTable
tags: [{ name: datatable, id: users }]
arguments: [ '#doctrine' ]
I get the error message:
You forgot your namespace for your UsersDataTable class file. I think it should be
namespace App\DataTable\UsersDataTable;
Also change the one in services.yaml

Symfony FOSRestBundle add custom header to response

I use FOSRestBundle in Symfony 4 to API project. I use annotations and in controller I have for example
use FOS\RestBundle\Controller\Annotations as Rest;
/**
* #Rest\Get("/api/user", name="index",)
* #param UserRepository $userRepository
* #return array
*/
public function index(UserRepository $userRepository): array
{
return ['status' => 'OK', 'data' => ['users' => $userRepository->findAll()]];
}
config/packages/fos_rest.yaml
fos_rest:
body_listener: true
format_listener:
rules:
- { path: '^/api', priorities: ['json'], fallback_format: json, prefer_extension: false }
param_fetcher_listener: true
view:
view_response_listener: 'force'
formats:
json: true
Now I'd like to add custom header 'X-Total-Found' to my response. How to do it?
You are relying in FOSRestBundle ViewListener, so that gives you limited options, like not being able to pass custom headers. In order to achieve what you want, you will need to call $this->handleView() from your controller and pass it a valid View instance.
You can use the View::create() factory method or the controller $this->view() shortcut. Both take as arguments the array of your data, the status code, and a response headers array. Then, you can set up your custom header there, but you will have to do that for every call.
The other option you have, which is more maintainable, is register a on_kernel_response event listener/subscriber and somehow pass it the value of your custom header (you could store it in a request attribute for example).
Those are the two options you have. You may have a third one, but I cannot come up with it at the minute.
I ran into the same issue. We wanted to move pagination meta information to the headers and leave the response without an envelope (data and meta properties).
My Environment
Symfony Version 5.2
PHP Version 8
FOS Rest Bundle
STEP 1: Create an object to hold the header info
// src/Rest/ResponseHeaderBag.php
namespace App\Rest;
/**
* Store header information generated in the controller. This same
* object is used in the response subscriber.
* #package App\Rest
*/
class ResponseHeaderBag
{
protected array $data = [];
/**
* #return array
*/
public function getData(): array
{
return $this->data;
}
/**
* #param array $data
* #return ResponseHeaderBag
*/
public function setData(array $data): ResponseHeaderBag
{
$this->data = $data;
return $this;
}
public function addData(string $key, $datum): ResponseHeaderBag
{
$this->data[$key] = $datum;
return $this;
}
}
STEP 2: Inject the ResponseHeaderBag into the controller action
public function searchCustomers(
ResponseHeaderBag $responseHeaderBag
): array {
...
...
...
// replace magic strings and numbers with class constants and real values.
$responseHeaderBag->add('X-Pagination-Count', 8392);
...
...
...
}
STEP 3: Register a Subscriber and listen for the Response Kernel event
// config/services.yaml
App\EventListener\ResponseSubscriber:
tags:
- kernel.event_subscriber
Subscribers are a great way to listen for events.
// src/EventListener/ResponseSubscriber
namespace App\EventListener;
use App\Rest\ResponseHeaderBag;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ResponseSubscriber implements EventSubscriberInterface
{
public function __construct(
protected ResponseHeaderBag $responseHeaderBag
){
}
/**
* #inheritDoc
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::RESPONSE => ['addAdditionalResponseHeaders']
];
}
/**
* Add the response headers created elsewhere in the code.
* #param ResponseEvent $event
*/
public function addAdditionalResponseHeaders(ResponseEvent $event): void
{
$response = $event->getResponse();
foreach ($this->responseHeaderBag->getData() as $key => $datum) {
$response->headers->set($key, $datum);
}
}
}

cannot binding a kernel.view EventListener to FOSUserBundle

I separated mobile and web requests with the help of kernel.view Event Listener.
The logic works like this:
if request is coming from mobile, then load xxx.mobile.twig
if request is coming from web, then load xxx.html.twig
This is working with my CustomBundle without any problem. In addition to it I'm using FOSUserBundle and HWIOAuthBundle with some of their routes. I checked var/logs/dev.log and I can't see kernel.view events regarding these bundles routes and eventually my listener cannot work with these bundles.
Could you give me an idea how could I bind to the kernel.view event for those bundles?
/**
* #param GetResponseForControllerResultEvent $event
* #return bool
*/
public function onKernelView(GetResponseForControllerResultEvent $event)
{
if (!$this->isMobileRequest($event->getRequest()->headers->get('user-agent'))) {
return false;
}
$template = $event->getRequest()->attributes->get('_template');
if (!$template) {
return false;
}
$templateReference = $this->templateNameParser->parse($template);
if ($templateReference->get('format') == 'html' && $templateReference->get('bundle') == 'CustomBundle') {
$mobileTemplate = sprintf(
'%s:%s:%s.mobile.twig',
$templateReference->get('bundle'),
$templateReference->get('controller'),
$templateReference->get('name')
);
if ($this->templating->exists($mobileTemplate)) {
$templateReference->set('format', 'mobile');
$event->getRequest()->attributes->set('_template', $templateReference);
}
}
}
There are a few things you should consider when debugging event related issues on Symfony2.
Events propagation can be stopped
it could be that another listener is listening for the very same event and is stopping the event propagation by calling $event->stopPropagation(). In that case make sure your listener is executed first (see point 2 below).
Event listeners have priorities
When defining a listener you can set its priority like shown below:
view_response_listener:
class: AppBundle\EventListener\ViewResponseListener
tags:
# The highest the priority, the earlier a listener is executed
# #see http://symfony.com/doc/2.7/cookbook/event_dispatcher/event_listener.html#creating-an-event-listener
- { name: kernel.event_listener, event: kernel.view, method: onKernelView, priority: 101 }
The other optional tag attribute is called priority, which defaults to 0 and it controls the order in which listeners are executed (the highest the priority, the earlier a listener is executed). This is useful when you need to guarantee that one listener is executed before another. The priorities of the internal Symfony listeners usually range from -255 to 255 but your own listeners can use any positive or negative integer.
Source: http://symfony.com/doc/2.7/cookbook/event_dispatcher/event_listener.html#creating-an-event-listener
Usually the dispatch of those events is done in the bootstrap file
Make sure regenerate your bootstrap file (and clear the cache now that you're at it!), especially if you're playing with priorities and/or your configuration.
composer run-script post-update-cmd
php app/console cache:clear --env=dev
The composer post-update-cmd will regenerate your bootstrap file but it will also do other things like reinstalling your assets which is probably something that you don't need. To just regenerate the bootstrap file check my answer here.
I find the solution, it is however a bit workaround, working properly now.
I put following function to my MobileTemplateListener.php file.
More details are here -> http://www.99bugs.com/handling-mobile-template-switching-in-symfony2/
/**
* #param GetResponseEvent $event
*/
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
if ($this->isMobileRequest($request->headers->get('user-agent')))
{
//ONLY AFFECT HTML REQUESTS
//THIS ENSURES THAT YOUR JSON REQUESTS TO E.G. REST API, DO NOT GET SERVED TEXT/HTML CONTENT-TYPE
if ($request->getRequestFormat() == "html")
{
$request->setRequestFormat('mobile');
}
}
}
/**
* Returns true if request is from mobile device, otherwise false
* #return boolean mobileUA
*/
private function isMobileRequest($userAgent)
{
if (preg_match('/(android|blackberry|iphone|ipad|phone|playbook|mobile)/i', $userAgent)) {
return true;
}
return false;
}
as a result when kernel.request event listener starts to handling, it is setting the format with value mobile
I was using FOSUserBundle through a child bundle to manipulate for my needs. You may find more details here -> https://github.com/FriendsOfSymfony/FOSUserBundle/blob/master/Resources/doc/overriding_controllers.md
for instance : we assume SecurityController.php
I created a file named SecurityController.php under my UserBundle It looks like following.
<?php
namespace Acme\UserBundle\Controller;
use Symfony\Component\HttpFoundation\RedirectResponse;
use FOS\UserBundle\Controller\SecurityController as BaseController;
use Symfony\Component\HttpFoundation\Request;
use Acme\UserBundle\Overrides\ControllerOverrideRenderTrait;
class SecurityController extends BaseController
{
use ControllerOverrideRenderTrait;
public function loginAction(Request $request)
{
$securityContext = $this->container->get('security.context');
$router = $this->container->get('router');
if ($securityContext->isGranted('IS_AUTHENTICATED_FULLY')) {
return new RedirectResponse($router->generate('my_profile_dashboard'), 307);
}
return parent::loginAction($request);
}
}
for all other FOS controllers I have to override render function. Which is provided by smyfony Symfony\Bundle\FrameworkBundle\Controller
But already my child bundle extends the FOSUserBundle's controllers, the only way to override this without duplicates of code is to use traits.
and I created one trait as following.
<?php
namespace Acme\UserBundle\Overrides;
use Symfony\Component\HttpFoundation\Response;
trait ControllerOverrideRenderTrait {
/**
* This overrides vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php
* Renders a view.
*
* #param string $view The view name
* #param array $parameters An array of parameters to pass to the view
* #param Response $response A response instance
*
* #return Response A Response instance
*/
public function render($view, array $parameters = array(), Response $response = null)
{
$format = $this->getRequest()->getRequestFormat();
$view = str_replace('.html.', '.' . $format . '.', $view);
return $this->container->get('templating')->renderResponse($view, $parameters, $response);
}
}
The Original function provided by symfony is the following.
/**
* Renders a view.
*
* #param string $view The view name
* #param array $parameters An array of parameters to pass to the view
* #param Response $response A response instance
*
* #return Response A Response instance
*/
public function render($view, array $parameters = array(), Response $response = null)
{
return $this->container->get('templating')->renderResponse($view, $parameters, $response);
}
Basically my change replaces '.html.' part in template name by providing $format which setted via onKernelRequest EventListener.
don't forget to add your service definition in services.yml
services:
acme.frontend.listener.mobile_template_listener:
class: Acme\FrontendBundle\EventListener\MobileTemplateListener
arguments: ['#templating', '#templating.name_parser']
tags:
- { name: kernel.event_listener, event: kernel.view, method: onKernelView }
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

Symfony2 Custom Error Exception Listener - Rendering templates or passing to a controller

I'm trying to work out the best way of handling custom error pages within Symfony2.This includes 500 and 404's etc.
I can create my own custom templates (error404.html.twig etc) and render these out fine, the issue is , the app requires a few variables be passed into the base template for the page to remain consistent. Using the built in exception handler results in required variables not being available.
I have successfully setup a custom Exception Event Listener, and registered it as a service:
namespace MyCo\MyBundle\Listener;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Bundle\TwigBundle\TwigEngine;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
class MyErrorExceptionListener
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
// We get the exception object from the received event
$exception = $event->getException();
if($exception->getStatusCode() == 404)
{
//$engine = $this->container->get('templating');
//$content = $engine->render('MyBundle:Default:error404.html.twig');
//return $response = new Response($content);
/* Also Tried */
//$templating = $this->container->get('templating');
//return $this->render('MyBundle:Default:index.html.twig');
$response = new Response($templating->render('MyBundle:Exception:error404.html.twig', array(
'exception' => $exception
)));
$event->setResponse($response);
}
}
}
This doesn't work , as :$container is not available , meaning I cannot render my custom page.
So two questions really , is this the correct way to handle custom error pages, or should I pass the response off to a controller? If so , whats the best way of doing that?
If this is correct , how can I make the templating engine available within my Listener ?
You should add into yours Listener
/**
*
* #var ContainerInterface
*/
private $container;
function __construct($container) {
$this->container = $container;
}
How do you register your Listener?
You should register Listener like Service
Like that
core.exceptlistener:
class: %core.exceptlistener.class%
arguments: [#service_container]
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException, priority: 200 }
The best way is don't use service_container.
The best way is register only necessary services.
Like that
/**
*
* #var Twig_Environment
*/
private $twig;
function __construct($twig) {
$this->twig = $twig;
}
The way I did to solve similar issue is: I dont know if it can help you out.
const GENERIC_CODE = 550;
public function onKernelException(GetResponseForExceptionEvent $event)
{
$exception = $event->getException();
if ($exception instanceof RedirectableException) {
$request = $event->getRequest();
$url = $exception->getUrl($this->_authentication['base']['url']);
$response = new Response();
$response->setStatusCode(self::GENERIC_CODE, AuthenticationException::GENERIC_MESSAGE);
$response->setContent($url);
$event->setResponse($response);
}
}

Symfony2 default locale in routing

I have a problem with routing and the internationalization of my site built with Symfony2.
If I define routes in the routing.yml file, like this:
example:
pattern: /{_locale}/example
defaults: { _controller: ExampleBundle:Example:index, _locale: fr }
It works fine with URLs like:
mysite.com/en/example
mysite.com/fr/example
But doesn't work with
mysite.com/example
Could it be that optional placeholders are permitted only at the end of an URL ?
If yes, what could be a possible solution for displaying an url like :
mysite.com/example
in a default language or redirecting the user to :
mysite.com/defaultlanguage/example
when he visits :
mysite.com/example. ?
I'm trying to figure it out but without success so far.
Thanks.
If someone is interested in, I succeeded to put a prefix on my routing.yml without using other bundles.
So now, thoses URLs work :
www.example.com/
www.example.com//home/
www.example.com/fr/home/
www.example.com/en/home/
Edit your app/config/routing.yml:
ex_example:
resource: "#ExExampleBundle/Resources/config/routing.yml"
prefix: /{_locale}
requirements:
_locale: |fr|en # put a pipe "|" first
Then, in you app/config/parameters.yml, you have to set up a locale
parameters:
locale: en
With this, people can access to your website without enter a specific locale.
You can define multiple patterns like this:
example_default:
pattern: /example
defaults: { _controller: ExampleBundle:Example:index, _locale: fr }
example:
pattern: /{_locale}/example
defaults: { _controller: ExampleBundle:Example:index}
requirements:
_locale: fr|en
You should be able to achieve the same sort of thing with annotations:
/**
* #Route("/example", defaults={"_locale"="fr"})
* #Route("/{_locale}/example", requirements={"_locale" = "fr|en"})
*/
Hope that helps!
This is what I use for automatic locale detection and redirection, it works well and doesn't require lengthy routing annotations:
routing.yml
The locale route handles the website's root and then every other controller action is prepended with the locale.
locale:
path: /
defaults: { _controller: AppCoreBundle:Core:locale }
main:
resource: "#AppCoreBundle/Controller"
prefix: /{_locale}
type: annotation
requirements:
_locale: en|fr
CoreController.php
This detects the user's language and redirects to the route of your choice. I use home as a default as that it the most common case.
public function localeAction($route = 'home', $parameters = array())
{
$this->getRequest()->setLocale($this->getRequest()->getPreferredLanguage(array('en', 'fr')));
return $this->redirect($this->generateUrl($route, $parameters));
}
Then, the route annotations can simply be:
/**
* #Route("/", name="home")
*/
public function indexAction(Request $request)
{
// Do stuff
}
Twig
The localeAction can be used to allow the user to change the locale without navigating away from the current page:
{{ targetLanguage }}
Clean & simple!
The Joseph Astrahan's solution of LocalRewriteListener works except for route with params because of $routePath == "/{_locale}".$path)
Ex : $routePath = "/{_locale}/my/route/{foo}" is different of $path = "/{_locale}/my/route/bar"
I had to use UrlMatcher (link to Symfony 2.7 api doc) for matching the actual route with the url.
I change the isLocaleSupported for using browser local code (ex : fr -> fr_FR). I use the browser locale as key and the route locale as value. I have an array like this array(['fr_FR'] => ['fr'], ['en_GB'] => 'en'...) (see the parameters file below for more information)
The changes :
Check if the local given in request is suported. If not, use the default locale.
Try to match the path with the app route collection. If not do nothing (the app throw a 404 if route doesn't exist). If yes, redirect with the right locale in route param.
Here is my code. Works for any route with or without param. This add the locale only when {_local} is set in the route.
Routing file (in my case, the one in app/config)
app:
resource: "#AppBundle/Resources/config/routing.yml"
prefix: /{_locale}/
requirements:
_locale: '%app.locales%'
defaults: { _locale: %locale%}
The parameter in app/config/parameters.yml file
locale: fr
app.locales: fr|gb|it|es
locale_supported:
fr_FR: fr
en_GB: gb
it_IT: it
es_ES: es
services.yml
app.eventListeners.localeRewriteListener:
class: AppBundle\EventListener\LocaleRewriteListener
arguments: ["#router", "%kernel.default_locale%", "%locale_supported%"]
tags:
- { name: kernel.event_subscriber }
LocaleRewriteListener.php
<?php
namespace AppBundle\EventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
class LocaleRewriteListener implements EventSubscriberInterface
{
/**
* #var Symfony\Component\Routing\RouterInterface
*/
private $router;
/**
* #var routeCollection \Symfony\Component\Routing\RouteCollection
*/
private $routeCollection;
/**
* #var urlMatcher \Symfony\Component\Routing\Matcher\UrlMatcher;
*/
private $urlMatcher;
/**
* #var string
*/
private $defaultLocale;
/**
* #var array
*/
private $supportedLocales;
/**
* #var string
*/
private $localeRouteParam;
public function __construct(RouterInterface $router, $defaultLocale = 'fr', array $supportedLocales, $localeRouteParam = '_locale')
{
$this->router = $router;
$this->routeCollection = $router->getRouteCollection();
$this->defaultLocale = $defaultLocale;
$this->supportedLocales = $supportedLocales;
$this->localeRouteParam = $localeRouteParam;
$context = new RequestContext("/");
$this->matcher = new UrlMatcher($this->routeCollection, $context);
}
public function isLocaleSupported($locale)
{
return array_key_exists($locale, $this->supportedLocales);
}
public function onKernelRequest(GetResponseEvent $event)
{
//GOAL:
// Redirect all incoming requests to their /locale/route equivalent when exists.
// Do nothing if it already has /locale/ in the route to prevent redirect loops
// Do nothing if the route requested has no locale param
$request = $event->getRequest();
$baseUrl = $request->getBaseUrl();
$path = $request->getPathInfo();
//Get the locale from the users browser.
$locale = $request->getPreferredLanguage();
if ($this->isLocaleSupported($locale)) {
$locale = $this->supportedLocales[$locale];
} else if ($locale == ""){
$locale = $request->getDefaultLocale();
}
$pathLocale = "/".$locale.$path;
//We have to catch the ResourceNotFoundException
try {
//Try to match the path with the local prefix
$this->matcher->match($pathLocale);
$event->setResponse(new RedirectResponse($baseUrl.$pathLocale));
} catch (\Symfony\Component\Routing\Exception\ResourceNotFoundException $e) {
} catch (\Symfony\Component\Routing\Exception\MethodNotAllowedException $e) {
}
}
public static function getSubscribedEvents()
{
return array(
// must be registered before the default Locale listener
KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
);
}
}
Symfony3
app:
resource: "#AppBundle/Controller/"
type: annotation
prefix: /{_locale}
requirements:
_locale: en|bg| # put a pipe "|" last
There is my Solution, it makes this process faster!
Controller:
/**
* #Route("/change/locale/{current}/{locale}/", name="locale_change")
*/
public function setLocaleAction($current, $locale)
{
$this->get('request')->setLocale($locale);
$referer = str_replace($current,$locale,$this->getRequest()->headers->get('referer'));
return $this->redirect($referer);
}
Twig:
<li {% if app.request.locale == language.locale %} class="selected" {% endif %}>
{{ language.locale }}
</li>
I have a full solution to this that I discovered after some research. My solution assumes that you want every route to have a locale in front of it, even login. This is modified to support Symfony 3, but I believe it will still work in 2.
This version also assumes you want to use the browsers locale as the default locale if they go to a route like /admin, but if they go to /en/admin it will know to use en locale. This is the case for example #2 below.
So for example:
1. User Navigates To -> "/" -> (redirects) -> "/en/"
2. User Navigates To -> "/admin" -> (redirects) -> "/en/admin"
3. User Navigates To -> "/en/admin" -> (no redirects) -> "/en/admin"
In all scenarios the locale will be set correctly how you want it for use throughout your program.
You can view the full solution below which includes how to make it work with login and security, otherwise the Short Version will probably work for you:
Full Version
Symfony 3 Redirect All Routes To Current Locale Version
Short Version
To make it so that case #2 in my examples is possible you need to do so using a httpKernal listner
LocaleRewriteListener.php
<?php
//src/AppBundle/EventListener/LocaleRewriteListener.php
namespace AppBundle\EventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\RouteCollection;
class LocaleRewriteListener implements EventSubscriberInterface
{
/**
* #var Symfony\Component\Routing\RouterInterface
*/
private $router;
/**
* #var routeCollection \Symfony\Component\Routing\RouteCollection
*/
private $routeCollection;
/**
* #var string
*/
private $defaultLocale;
/**
* #var array
*/
private $supportedLocales;
/**
* #var string
*/
private $localeRouteParam;
public function __construct(RouterInterface $router, $defaultLocale = 'en', array $supportedLocales = array('en'), $localeRouteParam = '_locale')
{
$this->router = $router;
$this->routeCollection = $router->getRouteCollection();
$this->defaultLocale = $defaultLocale;
$this->supportedLocales = $supportedLocales;
$this->localeRouteParam = $localeRouteParam;
}
public function isLocaleSupported($locale)
{
return in_array($locale, $this->supportedLocales);
}
public function onKernelRequest(GetResponseEvent $event)
{
//GOAL:
// Redirect all incoming requests to their /locale/route equivlent as long as the route will exists when we do so.
// Do nothing if it already has /locale/ in the route to prevent redirect loops
$request = $event->getRequest();
$path = $request->getPathInfo();
$route_exists = false; //by default assume route does not exist.
foreach($this->routeCollection as $routeObject){
$routePath = $routeObject->getPath();
if($routePath == "/{_locale}".$path){
$route_exists = true;
break;
}
}
//If the route does indeed exist then lets redirect there.
if($route_exists == true){
//Get the locale from the users browser.
$locale = $request->getPreferredLanguage();
//If no locale from browser or locale not in list of known locales supported then set to defaultLocale set in config.yml
if($locale=="" || $this->isLocaleSupported($locale)==false){
$locale = $request->getDefaultLocale();
}
$event->setResponse(new RedirectResponse("/".$locale.$path));
}
//Otherwise do nothing and continue on~
}
public static function getSubscribedEvents()
{
return array(
// must be registered before the default Locale listener
KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
);
}
}
To understand how that is working look up the event subscriber interface on symfony documentation.
To activate the listner you need to set it up in your services.yml
services.yml
# Learn more about services, parameters and containers at
# http://symfony.com/doc/current/book/service_container.html
parameters:
# parameter_name: value
services:
# service_name:
# class: AppBundle\Directory\ClassName
# arguments: ["#another_service_name", "plain_value", "%parameter_name%"]
appBundle.eventListeners.localeRewriteListener:
class: AppBundle\EventListener\LocaleRewriteListener
arguments: ["#router", "%kernel.default_locale%", "%locale_supported%"]
tags:
- { name: kernel.event_subscriber }
Finally this refers to variables that need to be defined in your config.yml
config.yml
# Put parameters here that don't need to change on each machine where the app is deployed
# http://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app.locales: en|es|zh
locale_supported: ['en','es','zh']
Finally, you need to make sure all your routes start with /{locale} for now on. A sample of this is below in my default controller.php
<?php
namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
/**
* #Route("/{_locale}", requirements={"_locale" = "%app.locales%"})
*/
class DefaultController extends Controller
{
/**
* #Route("/", name="home")
*/
public function indexAction(Request $request)
{
$translated = $this->get('translator')->trans('Symfony is great');
// replace this example code with whatever you need
return $this->render('default/index.html.twig', [
'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
'translated' => $translated
]);
}
/**
* #Route("/admin", name="admin")
*/
public function adminAction(Request $request)
{
$translated = $this->get('translator')->trans('Symfony is great');
// replace this example code with whatever you need
return $this->render('default/index.html.twig', [
'base_dir' => realpath($this->container->getParameter('kernel.root_dir').'/..'),
'translated' => $translated
]);
}
}
?>
Note the requirements requirements={"_locale" = "%app.locales%"}, this is referencing the config.yml file so you only have to define those requirements in one place for all routes.
Hope this helps someone :)
We created a custom RoutingLoader that adds a localized version to all routes. You inject an array of additional locales ['de', 'fr'] and the Loader adds a route for each additional locale. The main advantage is, that for your default locale, the routes stay the same and no redirect is needed. Another advantage is, that the additionalRoutes are injected, so they can be configured differently for multiple clients/environments, etc. And much less configuration.
partial_data GET ANY ANY /partial/{code}
partial_data.de GET ANY ANY /de/partial/{code}
partial_data.fr GET ANY ANY /fr/partial/{code}
Here is the loader:
<?php
namespace App\Routing;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
class I18nRoutingLoader extends Loader
{
const NAME = 'i18n_annotation';
private $projectDir;
private $additionalLocales = [];
public function __construct(string $projectDir, array $additionalLocales)
{
$this->projectDir = $projectDir;
$this->additionalLocales = $additionalLocales;
}
public function load($resource, $type = null)
{
$collection = new RouteCollection();
// Import directly for Symfony < v4
// $originalCollection = $this->import($resource, 'annotation')
$originalCollection = $this->getOriginalRouteCollection($resource);
$collection->addCollection($originalCollection);
foreach ($this->additionalLocales as $locale) {
$this->addI18nRouteCollection($collection, $originalCollection, $locale);
}
return $collection;
}
public function supports($resource, $type = null)
{
return self::NAME === $type;
}
private function getOriginalRouteCollection(string $resource): RouteCollection
{
$resource = realpath(sprintf('%s/src/Controller/%s', $this->projectDir, $resource));
$type = 'annotation';
return $this->import($resource, $type);
}
private function addI18nRouteCollection(RouteCollection $collection, RouteCollection $definedRoutes, string $locale): void
{
foreach ($definedRoutes as $name => $route) {
$collection->add(
$this->getI18nRouteName($name, $locale),
$this->getI18nRoute($route, $name, $locale)
);
}
}
private function getI18nRoute(Route $route, string $name, string $locale): Route
{
$i18nRoute = clone $route;
return $i18nRoute
->setDefault('_locale', $locale)
->setDefault('_canonical_route', $name)
->setPath(sprintf('/%s%s', $locale, $i18nRoute->getPath()));
}
private function getI18nRouteName(string $name, string $locale): string
{
return sprintf('%s.%s', $name, $locale);
}
}
Service definition (SF4)
App\Routing\I18nRoutingLoader:
arguments:
$additionalLocales: "%additional_locales%"
tags: ['routing.loader']
Routing definition
frontend:
resource: ../../src/Controller/Frontend/
type: i18n_annotation #localized routes are added
api:
resource: ../../src/Controller/Api/
type: annotation #default loader, no routes are added
I use annotations, and i will do
/**
* #Route("/{_locale}/example", defaults={"_locale"=""})
* #Route("/example", defaults={"_locale"="en"}, , requirements = {"_locale" = "fr|en|uk"})
*/
But for yml way, try some equivalent...
Maybe I solved this in a reasonably simple way:
example:
path: '/{_locale}{_S}example'
defaults: { _controller: 'AppBundle:Example:index' , _locale="de" , _S: "/" }
requirements:
_S: "/?"
_locale: '|de|en|fr'
Curious about the judgement of the critics ...
Best wishes,
Greg
root:
pattern: /
defaults:
_controller: FrameworkBundle:Redirect:urlRedirect
path: /en
permanent: true
How to configure a redirect to another route without a custom controller
I think you could simply add a route like this:
example:
pattern: /example
defaults: { _controller: ExampleBundle:Example:index }
This way, the locale would be the last locale selected by the user, or the default locale if user locale has not been set. You might also add the "_locale" parameter to the "defaults" in your routing config if you want to set a specific locale for /example:
example:
pattern: /example
defaults: { _controller: ExampleBundle:Example:index, _locale: fr }
I don't know if there's a better way to do this.

Resources