Master Doctrine in PrestaShop: The Clean Way to Handle Dynamic DB Prefixes

posted 3 min read

If you are modernizing your PrestaShop development workflow by adopting Doctrine, you have likely hit a specific wall. It's that moment when your perfectly valid Entity throws a SQLSTATE[42S02]: Base table or view not found error.

The table exists. The database is connected. So, what's broken?

The culprit is almost always the Database Prefix. PrestaShop relies on them (ps, shop, custom_), but Doctrine doesn't natively "speak" PrestaShop prefix logic.

Here is how to solve this permanently using a robust Event Subscriber, ensuring your modules are portable, clean, and professional.

The Conflict: Doctrine Rigidity vs. PrestaShop Flexibility

PrestaShop is designed to be installed anywhere, meaning the database prefix is a variable defined at installation (_DBPREFIX).

PrestaShop expects tables like ps_product or shop_orders.

Doctrine expects exactly what you type in the annotation: @ORM\Table(name="product") looks for product.

The "Quick Fix" Trap

Junior developers often solve this by hardcoding the prefix into the Entity:

// ⛔ NEVER DO THIS
/**
 * @ORM\Table(name="ps_my_table")
 */
class MyEntity { ... }

Why this fails:

  • It breaks if a user installs your module on a shop with a prefix other than ps_.
  • It makes CI/CD and testing nightmares if environments differ.

The Professional Solution: Doctrine Event Subscribers

The correct architectural pattern is to intercept Doctrine before it maps the class to the database and dynamically inject the current shop's prefix. We do this using the loadClassMetadata event.

1. Create the Prefix Subscriber

Create a new class in your module structure, for example at src/Doctrine/TablePrefixSubscriber.php.

This listener will check if an Entity belongs to your module, and if so, prepend the correct prefix to both the main table and any association tables (Many-to-Many).

<?php

namespace Vendor\YourModule\Doctrine;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;

class TablePrefixSubscriber implements EventSubscriber
{
    public function __construct(
        private readonly string $dbPrefix
    ) {}

    public function getSubscribedEvents(): array
    {
        return [Events::loadClassMetadata];
    }

    public function loadClassMetadata(LoadClassMetadataEventArgs $args): void
    {
        $classMetadata = $args->getClassMetadata();

        // ️ CRITICAL: Only apply this to YOUR module's entities.
        // Touching Core or other modules' entities can cause system-wide crashes.
        $myModuleNamespace = 'Vendor\\YourModule\\Entity\\';
        
        if (!str_starts_with($classMetadata->getName(), $myModuleNamespace)) {
            return;
        }

        // 1. Prefix the main table
        $this->prefixMainTable($classMetadata);

        // 2. Prefix any ManyToMany join tables
        $this->prefixJoinTables($classMetadata);
    }

    private function prefixMainTable($classMetadata): void
    {
        $currentName = $classMetadata->getTableName();

        if (!str_starts_with($currentName, $this->dbPrefix)) {
            $classMetadata->setPrimaryTable([
                'name' => $this->dbPrefix . $currentName
            ]);
        }
    }

    private function prefixJoinTables($classMetadata): void
    {
        $mappings = $classMetadata->getAssociationMappings();

        foreach ($mappings as $fieldName => $mapping) {
            if ($mapping['type'] == \Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY && isset($mapping['joinTable']['name'])) {
                $joinTableName = $mapping['joinTable']['name'];
                
                if (!str_starts_with($joinTableName, $this->dbPrefix)) {
                    $mappings[$fieldName]['joinTable']['name'] = $this->dbPrefix . $joinTableName;
                }
            }
        }
    }
}

2. Register the Service

Now, tell Symfony (which powers modern PrestaShop modules) about your subscriber. Edit your config/services.yml:

services:
  Vendor\YourModule\Doctrine\TablePrefixSubscriber:
    arguments:
      - '%database_prefix%'  # PrestaShop automatically provides this parameter
    tags:
      - { name: doctrine.event_subscriber }

3. Write Clean Entities

Now you can write your Entities exactly how they should look—agnostic of the configuration.

namespace Vendor\YourModule\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="trade_in_request")
 * @ORM\Entity()
 */
class TradeInRequest
{
    // ... standard properties
}

At runtime, Doctrine will now see ps_trade_in_request (or whatever the prefix is), mapping it correctly to the database.

Don't Forget the Installer!

Your PHP code is now smart, but your SQL installer still needs to create the table. Ensure your install.sql uses the PrestaShop variable replacement:

/* The classic PrestaShop installer replaces _DB_PREFIX_ automatically */
CREATE TABLE IF NOT EXISTS `_DB_PREFIX_trade_in_request` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    /* ... columns ... */
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Note: If you use a manual PHP installer, use the $prefix variable passed typically in the install method.

Why This Method Wins

  • Zero-Touch Deployment: You can deploy this module to 50 different shops with 50 different prefixes; it will work without a single code change.
  • Security: By checking the namespace (str_starts_with), you ensure you aren't accidentally renaming Core tables (like accidentally renaming ps_product to ps_ps_product).
  • Future Proofing: If PrestaShop changes how prefixes work in v9 or v10, you only have one file to update, rather than 20 Entity files.

Testing Your Implementation

After clearing your cache (bin/console cache:clear), you can verify the mapping in a Kernel test case to ensure the prefix is actually being applied:

public function testPrefixIsAppliedDynamically(): void
{
    self::bootKernel();
    $em = self::getContainer()->get('doctrine.orm.entity_manager');
    
    $meta = $em->getClassMetadata(TradeInRequest::class);
    
    // Assertion: The table name Doctrine sees should start with the active prefix
    $this->assertStringStartsWith(_DB_PREFIX_, $meta->getTableName());
}

This approach turns a common frustration into a "solved problem," allowing you to focus on business logic rather than database plumbing.

2 Comments

0 votes
1 vote

More Posts

Master the Arena: Expert Guide to 1v1 LOL Unblocked 76

mktplace_io - Nov 22

The Complete Roadmap to Master Cryptography From Beginner to Expert

mohamed.cybersec - Oct 23

Master CSS3: Elevate Your Web Development Skills to the Next Level

Brian Keary - Jan 23

Main approaches to work with dynamic strings in Angular templates.

Sunny - May 14

How To Build A Dynamic Modal In Next.js

chukwuemeka Emmanuel - Apr 5
chevron_left