How to run a Doctrine transaction in Symfony

A database transaction gives a Symfony application one boundary for several Doctrine writes that must succeed or fail together. It matters when a command, controller, or message handler changes more than one row and partial data would leave the application in the wrong state.

Doctrine ORM wraps a normal flush() in a transaction, but explicit boundaries are needed when the unit of work mixes DBAL statements, repository calls, and application checks. The Connection::transactional() helper starts the transaction, commits after the callback returns, and rolls back if an exception leaves the callback.

For local proof, a disposable audit table makes commit and rollback behavior visible from a terminal. Replace the demo inserts with application writes before keeping the transaction pattern in a service or message handler.

Steps to run a Doctrine transaction in Symfony:

  1. Open a terminal in the Symfony project root that contains bin/console.
  2. Confirm that Doctrine DBAL can query the configured database.
    $ php bin/console dbal:run-sql 'SELECT 1 AS transaction_probe'
     ------------------- 
      transaction_probe  
     ------------------- 
      1                  
     ------------------- 

    Use a development database because the demo creates and drops the transaction_audit table.
    Related: How to configure a Doctrine database in Symfony

  3. Create the transaction demo command under src/Command.
    TransactionDemoCommand.php
    <?php
     
    namespace App\Command;
     
    use Doctrine\DBAL\Connection;
    use RuntimeException;
    use Symfony\Component\Console\Attribute\AsCommand;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputArgument;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
     
    #[AsCommand(name: 'app:transaction-demo')]
    final class TransactionDemoCommand extends Command
    {
        public function __construct(private readonly Connection $connection)
        {
            parent::__construct();
        }
     
        protected function configure(): void
        {
            $this->addArgument('mode', InputArgument::OPTIONAL, 'Use fail to test rollback.', 'commit');
        }
     
        protected function execute(InputInterface $input, OutputInterface $output): int
        {
            $mode = (string) $input->getArgument('mode');
     
            if (!in_array($mode, ['commit', 'fail'], true)) {
                $output->writeln('Use "commit" or "fail" as the mode.');
     
                return Command::INVALID;
            }
     
            $this->connection->executeStatement('CREATE TABLE IF NOT EXISTS transaction_audit (
                label VARCHAR(80) NOT NULL
            )');
            $this->connection->executeStatement('DELETE FROM transaction_audit');
     
            try {
                $this->connection->transactional(function (Connection $connection) use ($mode): void {
                    $connection->executeStatement(
                        'INSERT INTO transaction_audit (label) VALUES (?)',
                        ['order_reserved'],
                    );
     
                    if ($mode === 'fail') {
                        throw new RuntimeException('Simulated failure inside the transaction.');
                    }
     
                    $connection->executeStatement(
                        'INSERT INTO transaction_audit (label) VALUES (?)',
                        ['email_queued'],
                    );
                });
     
                $output->writeln('Committed transaction.');
            } catch (RuntimeException $exception) {
                $output->writeln('Rolled back transaction: '.$exception->getMessage());
            }
     
            $count = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM transaction_audit');
            $output->writeln(sprintf('Rows in transaction_audit: %d', $count));
     
            return Command::SUCCESS;
        }
    }

    Use EntityManagerInterface with wrapInTransaction() instead when the transaction only wraps ORM entity changes and should flush before commit.

  4. Clear the dev cache so Symfony rebuilds the command list.
    $ php bin/console cache:clear
    
     // Clearing the cache for the dev environment with debug true                  
    
     [OK] Cache for the "dev" environment (debug=true) was successfully cleared.    
  5. Run the rollback branch of the transaction.
    $ php bin/console app:transaction-demo fail
    Rolled back transaction: Simulated failure inside the transaction.
    Rows in transaction_audit: 0

    The first insert is rolled back because the callback throws before the second insert runs.

  6. Run the commit branch of the transaction.
    $ php bin/console app:transaction-demo commit
    Committed transaction.
    Rows in transaction_audit: 2
  7. List the rows committed by the transaction.
    $ php bin/console dbal:run-sql 'SELECT label FROM transaction_audit'
     ---------------- 
      label           
     ---------------- 
      order_reserved  
      email_queued    
     ---------------- 

    Both labels appear only after the callback returns without an exception.

  8. Drop the demo table after the transaction behavior is proven.
    $ php bin/console dbal:run-sql 'DROP TABLE transaction_audit'
    
     [OK] 0 rows affected.                                                          
  9. Remove the demo command file after copying the transaction block into the real service.
    $ rm src/Command/TransactionDemoCommand.php