Symfony is currently a mature and powerful framework. However, its documentation can sometimes seem confusing. Some simple things, or that may seem primordial, do not seem clearly indicated.

For example, logging errors thrown by Symfony in different places is not always easy. This tutorial is based on version 3.4 of Symfony.

I recently tried to log errors in a MySQL table.

My goal was to:

  1. To be able to log various information in the database thanks to the PHP Monolog framework
  2. Automatically log the errors thrown by Symfony in production (500, 404 …)

I used the default bundle, the Symfony Monolog Bundle, based on the excellent PHP Monolog library.

It is necessary to first create the entity that will save the logs in the database.

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Swagger\Annotations as SWG;
/**
 * @ORM\Entity(repositoryClass="AppBundle\Repository\LogRepository")
 * @ORM\Table(name="log")
 * @ORM\HasLifecycleCallbacks
 */
class Log
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="message", type="text")
     */
    private $message;

    /**
     * @ORM\Column(name="context", type="array")
     */
    private $context;

    /**
     * @ORM\Column(name="level", type="smallint")
     */
    private $level;

    /**
     * @ORM\Column(name="level_name", type="string", length=50)
     */
    private $levelName;

    /**
     * @ORM\Column(name="extra", type="array")
     */
    private $extra;

    /**
     * @ORM\Column(name="created_at", type="datetime")
     */
    private $createdAt;

    /**
     * @ORM\PrePersist
     */
    public function onPrePersist()
    {
        $this->createdAt = new \DateTime();
    }

    /**
     * Get id.
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set message.
     *
     * @param string $message
     *
     * @return Log
     */
    public function setMessage($message)
    {
        $this->message = $message;

        return $this;
    }

    /**
     * Get message.
     *
     * @return string
     */
    public function getMessage()
    {
        return $this->message;
    }

    /**
     * Set context.
     *
     * @param array $context
     *
     * @return Log
     */
    public function setContext($context)
    {
        $this->context = $context;

        return $this;
    }

    /**
     * Get context.
     *
     * @return array
     */
    public function getContext()
    {
        return $this->context;
    }

    /**
     * Set level.
     *
     * @param int $level
     *
     * @return Log
     */
    public function setLevel($level)
    {
        $this->level = $level;

        return $this;
    }

    /**
     * Get level.
     *
     * @return int
     */
    public function getLevel()
    {
        return $this->level;
    }

    /**
     * Set levelName.
     *
     * @param string $levelName
     *
     * @return Log
     */
    public function setLevelName($levelName)
    {
        $this->levelName = $levelName;

        return $this;
    }

    /**
     * Get levelName.
     *
     * @return string
     */
    public function getLevelName()
    {
        return $this->levelName;
    }

    /**
     * Set extra.
     *
     * @param array $extra
     *
     * @return Log
     */
    public function setExtra($extra)
    {
        $this->extra = $extra;

        return $this;
    }

    /**
     * Get extra.
     *
     * @return array
     */
    public function getExtra()
    {
        return $this->extra;
    }

    /**
     * Set createdAt.
     *
     * @param \DateTime $createdAt
     *
     * @return Log
     */
    public function setCreatedAt($createdAt)
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    /**
     * Get createdAt.
     *
     * @return \DateTime
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }
}

Once the entity created, we now need the service that will persist instances of our entity:

<?php
// src/AppBundle/Utils/MonologDBHandler.php

namespace AppBundle\Utils;

use AppBundle\Entity\Log;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Logger;

class MonologDBHandler extends AbstractProcessingHandler
{
    /**
     * @var EntityManagerInterface
     */
    protected $em;

    public function __construct(EntityManagerInterface $em, $level = Logger::ERROR, $bubble = true)
    {
        $this->em = $em;
        parent::__construct($level, $bubble);
    }

    /**
     * Called when writing to our database
     * @param array $record
     */
    protected function write(array $record)
    {
        try {

            $logEntry = new Log();
            $logEntry->setMessage($record['message']);
            $logEntry->setLevel($record['level']);
            $logEntry->setLevelName($record['level_name']);

            if(is_array($record['extra']))
            {
                $logEntry->setExtra($record['extra']);
            } else {
                $logEntry->setExtra([]);
            }

            if (is_array($record['context'])) {
                $logEntry->setExtra($record['context']);
            } else {
                $logEntry->setExtra([]);
            }


            $this->em->persist($logEntry);
            $this->em->flush();
        } catch (\Exception $e) {

        }
    }

}

The next step is to properly configure this new service in the app/config/services.yml file

# app/config/services.yml

# ...

services:
    # ...

    # Common handler that will allow us to log various information
    monolog.db_handler:
        class: AppBundle\Utils\MonologDBHandler
        # The 'services' type handlers must be configured manually, as specified here: https://github.com/symfony/monolog-bundle/issues/116
        # The arguments that will go into the constructor
        arguments:
            - '@doctrine.orm.entity_manager'
            - !php/const Monolog\Logger::DEBUG
            # 'Bubble' argument to false: Handlers after this one will not be called if the log has already been taken into account
            - false

    # Handler errors, filtering logs whose level is lower than "ERROR"
    monolog.error_db_handler:
        class: AppBundle\Utils\MonologDBHandler
        # The arguments that will go into the constructor
        arguments:
            - '@doctrine.orm.entity_manager'
            - !php/const Monolog\Logger::ERROR

    # ...

The last step is to configure monolog so that it uses our new service statements as handlers. An important point to note, and not specified in the Symfony documentation, is that the configuration of the service type handlers is done directly in their constructor, as seen on the previous file.

monolog:
    use_microseconds: false
    # Here we define new channels
    channels: ["app-channel"]

    handlers:
        db_handler:
            # The level of the handler is configured at the same service level: https://github.com/symfony/monolog-bundle/issues/116
            type: service
            id: monolog.db_handler
            channels: app-channel
    
        main:
            type: fingers_crossed
            action_level: error
            handler: grouped_handler

        # Handler type "group" to group other handlers
        grouped_handler:
            type: group
            members: ["nested_handler", "db_error_handler"]

        nested_handler:
            type:  stream
            path:  "php://stderr"
            # Minimum log level
            level: info
            # We use a Formatter based on Line Formatter to transfer fields if necessary
            # formatter: AppBundle\Formatter\LogFormatter

        db_error_handler:
            # The level of the handler is configured at the service level : https://github.com/symfony/monolog-bundle/issues/116
            type: service
            id: monolog.error_db_handler

To be able to use our new logger, the schema of the database must be updated, by creating the table associated with the ‘Log’ entity. Launch this Symfony command in a terminal:

php bin/console doctrine:schema:update --force

Finally, we can check that our loggers are operational. So we add:

$logger = $this->get('monolog.logger.app-channel');
$logger->info("This one goes to app-channel!!");

in the code of any controller. We then make a request and we check that the log has been persisted in the database.

One can also test the fact that the errors (here of type 500) are well logged as well. We add the following code in the same controller:

throw new \Exception();

And we re-make a request, checking then that the error has been logged by Symfony.