Comment migrer du type array vers JSON avec Doctrine
Doctrine a déprécié les types array
et object
en version 3. Il est temps de migrer vers un type plus interopérable, et moins sensibles au refactoring ! Vous l’aurez compris, il faut maintenant utiliser du JSON.
Dans cet article, nous verrons comment migrer ces colonnes facilement
Section intitulée etape-1-un-type-doctrine-hybrideÉtape 1 : Un type doctrine hybride
La première étape consiste à ajouter un support de compatibilité avec les deux types de colonnes. Nous allons ajouter un nouveau type Doctrine qui sait lire les deux types de colonnes. Il va d’abord tester de décoder la colonne au format JSON, et si ça échoue, il va essayer de décoder la colonne au format PHP.
<?php
namespace App\Doctrine\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ArrayType;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\JsonType;
class MigrateJsonNullable extends JsonType
{
private ArrayType $arrayType;
public function getSQLDeclaration(array $column, AbstractPlatform $platform)
{
$arrayType = $this->getArrayType();
$column['type'] = $arrayType;
return $arrayType->getSQLDeclaration($column, $platform);
}
public function convertToPHPValue($value, AbstractPlatform $platform)
{
try {
return parent::convertToPHPValue($value, $platform);
} catch (ConversionException) {
return $this->getArrayType()->convertToPHPValue($value, $platform);
}
}
public function getName(): string
{
return $this->getArrayType()->getName();
}
public function requiresSQLCommentHint(AbstractPlatform $platform)
{
return $this->getArrayType()->requiresSQLCommentHint($platform);
}
private function getArrayType(): ArrayType
{
return $this->arrayType ??= new ArrayType();
}
}
Il faut ensuite ajouter ce nouveau type dans le fichier de configuration de doctrine :
doctrine:
dbal:
types:
migrate_json_nullable: App\Doctrine\Type\MigrateJsonNullable
Et enfin, il faut changer le type de colonne :
- #[ORM\Column(type: 'array', nullable: true)]
+ #[ORM\Column(type: 'migrate_json_nullable', nullable: true)]
private array $foo;
Dès cette étape, il est techniquement possible de déployer, mais cela ne servira à rien, à part ralentir la production. Il faut donc passer à l’étape suivante.
Section intitulée etape-2-migrer-les-donneesÉtape 2 : Migrer les données
Ici, nous avons plus d’options pour migrer les données. L’avantage, c’est que nous pouvons migrer ces données sans bloquer le déploiement. Suivant le nombre d’enregistrements, nous pouvons choisir entre :
- Faire une commande qui va migrer les données en arrière plan
- Faire une commande qui va migrer les données en plusieurs fois, en utilisant un système de batch, et un système de queue, afin de migrer les données en parallèle.
Voici un exemple de commande
namespace App\Command\Tmp;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
#[AsCommand(
name: 'app:tmp:migrate-to-json',
description: 'Migrate all tables (that needs to be converted) to json',
)]
class MigrateToJsonCommand extends Command
{
private const array TABLES_TO_MIGRATE = [
'table_name_1' => ['id', ['column_name_1', 'column_name_2']],
'table_name_2' => ['uuid', ['column_name_1', 'column_name_2']],
];
public function __construct(
private readonly Connection $connection,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
#[Autowire(service: 'services_resetter')]
private readonly ServicesResetter $servicesResetter,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of records to migrate')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
gc_disable();
$limit = $input->getOption('limit');
ProgressBar::setFormatDefinition('custom', ' %current%/%max% [%bar%] %percent:3s%% ; %elapsed:6s%/%estimated:-6s% ; %memory:6s% ; %speed% msg/sec ; %error% error(s)');
foreach (self::TABLES_TO_MIGRATE as $tableName => [$idColumn, $columnNames]) {
$this->migrate($io, $tableName, $idColumn, $columnNames, $limit);
}
return Command::SUCCESS;
}
private function migrate(SymfonyStyle $io, string $tableName, string $idColumn, array $columnNames, ?int $limit): void
{
$io->title('Migration of ' . $tableName . '.' . implode(', ', $columnNames));
$tableName = $this->connection->quoteIdentifier($tableName);
$idColumn = $this->connection->quoteIdentifier($idColumn);
$limitSql = null;
if ($limit) {
$limitSql = 'LIMIT ' . $limit;
}
$columns = [];
$conditions = [];
foreach ($columnNames as $columnName) {
$columns[] = $this->connection->quoteIdentifier($columnName);
$conditions[] = \sprintf('NOT JSON_VALID(%s)', $this->connection->quoteIdentifier($columnName));
}
$columnNamesAsString = implode(', ', $columns);
$conditionsAsString = implode(' AND ', $conditions);
$records = $this->connection->fetchAllAssociative(<<<SQL
SELECT {$idColumn} as id, {$columnNamesAsString}
FROM {$tableName}
WHERE {$conditionsAsString}
{$limitSql}
SQL);
if (!$records) {
$io->success('No records to migrate');
return;
}
$paramsUpdateAsString = implode(' = ?, ', $columns) . ' = ?';
$stmt = $this->connection->prepare(\sprintf(
'UPDATE %s SET %s WHERE %s = ?',
$tableName,
$paramsUpdateAsString,
$idColumn,
));
$bar = $io->createProgressBar(\count($records));
$bar->setFormat('custom');
$bar->setMessage('0', 'speed');
$bar->setMessage('0', 'error');
$errors = [];
$prevErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$prevErrorHandler) {
if (__FILE__ === $file && !\in_array($type, [\E_DEPRECATED, \E_USER_DEPRECATED], true)) {
throw new \ErrorException($msg, 0, $type, $file, $line);
}
return $prevErrorHandler ? $prevErrorHandler($type, $msg, $file, $line, $context) : false;
});
$startedAt = microtime(true);
try {
foreach ($records as $i => $record) {
$oldValues = [];
foreach ($columnNames as $columnName) {
try {
$oldValues[$columnName] = unserialize($record[$columnName]);
} catch (\ErrorException) {
$errors[$record['id']] = true;
$oldValues[$columnName] = [];
}
}
try {
$newValues = array_map(fn ($oldValue) => json_encode($oldValue, \JSON_THROW_ON_ERROR), $oldValues);
} catch (\ErrorException) {
$errors[$record['id']] = true;
continue;
}
$stmt->executeStatement([
...array_values($newValues),
$record['id'],
]);
$bar->advance();
if (0 === $i % 100) {
$bar->setMessage((string) round(100 / (microtime(true) - $startedAt), 2), 'speed');
$bar->setMessage((string) \count($errors), 'error');
$startedAt = microtime(true);
if (0 === $i % 10_000) {
$this->servicesResetter->reset();
gc_collect_cycles();
}
}
}
} finally {
restore_error_handler();
}
$bar->finish();
$io->newLine();
$io->newLine();
$io->success('Migration done');
$errors = array_keys($errors);
if ($errors) {
$io->error(\sprintf('Some records (%d) could not be migrated', \count($errors)));
$io->listing($errors);
file_put_contents($this->projectDir . '/var/migrate_to_json.txt', implode("\n", $errors), \FILE_APPEND);
file_put_contents($this->projectDir . '/var/migrate_to_json.txt', "\n", \FILE_APPEND);
}
}
}
Section intitulée etape-2–5-le-cas-des-colonnes-not-nullÉtape 2.5 : Le cas des colonnes not null
Il y a un cas particulier à prendre en compte : les colonnes non-nullable.
Il était possible, pour une colonne de type array
ou object
, de contenir null
, même si elle était non-nullable ! En effet, doctrine va convertir un null
en N;
(valeur de retour de serialize(null)
). Donc la colonne n’est pas vide. 🤯. Ahhhhh les joies du code legacy non fortement typé 😎.
Si la colonne n’est pas nullable, il faut s’assurer de ne pas mettre de null
dans la colonne. Pour cela, nous allons ajouter un nouveau type qui transforme les null
en tableau vide :
<?php
namespace App\Doctrine\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
class MigrateJsonNotNull extends MigrateJsonNullable
{
public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
$value ??= [];
return parent::convertToDatabaseValue($value, $platform);
}
}
Ensuite, il faut changer le type de colonne :
- #[ORM\Column(type: 'migrate_json_nullable')]
+ #[ORM\Column(type: 'migrate_json_not_null')]
private array $foo;
Section intitulée etape-3-deployerEtape 3 : Déployer
Il faut maintenant déployer le code, et exécuter la commande de migration.
Et attendre que la migration soit terminée.
Section intitulée etape-4-supprimer-le-type-doctrine-hybrideÉtape 4 : Supprimer le type doctrine hybride
Une fois que toutes les données ont été migrées, il est temps de supprimer le type doctrine hybride, et de changer le type de colonne :
- #[ORM\Column(type: 'migrate_json_nullable', nullable: true)]
+ #[ORM\Column(type: 'json', nullable: true)]
private array $foo;
Il faut maintenant générer une migration de schéma, et déployer le tout.
Et voilà, vous avez migré vos colonnes array
en json
sans downtime !
Commentaires et discussions
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !

Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde
Ces clients ont profité de notre expertise
Ouibus a pour ambition de devenir la référence du transport en bus longue distance. Dans cette optique, les enjeux à venir de la compagnie sont nombreux (vente multi-produit, agrandissement du réseau, diminution du time-to-market, amélioration de l’expérience et de la satisfaction client) et ont des conséquences sur la structuration de la nouvelle…
LOOK Cycle bénéficie désormais d’une nouvelle plateforme eCommerce disponible sur 70 pays et 5 langues. La base technique modulaire de Sylius permet de répondre aux exigences de LOOK Cycle en terme de catalogue, produits, tunnel d’achat, commandes, expéditions, gestion des clients et des expéditions.
Nous avons réalisé différentes applications métier à l’aide de technologies comme Symfony2 et Titanium. Arianespace s’est appuyé sur l’expertise reconnue de JoliCode pour mettre en place des applications sur mesure, testées et réalisées avec un haut niveau de qualité.