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
Le projet visait à moderniser l’expérience tout en renforçant l’autonomie des équipes. Nous avons intégré un éditeur de quiz interactifs (questions multimédia, QCM, feedback immédiat, scoring), un gestionnaire de fichiers avec suivi des versions, et un système de notifications utilisateur. L’interface d’administration permet une mise à jour simple…
L’application repose sur un socle Symfony robuste, avec une interface d’administration EasyAdmin dédiée à la gestion des utilisateurs et des contenus (import de données depuis des documents excel). Les datavisualisations sont affichées et rendues réactives grâce au framework React. Côté expérience, l’utilisateur peut naviguer entre deux univers métiers…
Travailler sur un projet US présente plusieurs défis. En premier lieu : Le décalage horaire. Afin d’atténuer cet obstacle, nous avons planifié les réunions en début d’après-midi en France, ce qui permet de trouver un compromis acceptable pour les deux parties. Cette approche assure une participation optimale des deux côtés et facilite la communication…