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
Nous avons construit un extranet afin de de simplifier les tâches quotidiennes de gestion, que ce soit pour les utilisateurs (départements, associations, mandataires, accueillants et accueillis) et l’équipe de Cettefamille. Le socle technique utilisé est Symfony, PostgreSQL, Webpack, VanillaJS. L’emploi de ces technologies modernes permet aujourd’hui…
Dans le cadre d’une refonte complète de son architecture Web, le journal en ligne Mediapart a sollicité l’expertise de JoliCode afin d’accompagner ses équipes. Mediapart.fr est un des rares journaux 100% en ligne qui n’appartient qu’à ses lecteurs qui amène un fort traffic authentifiés et donc difficilement cachable. Pour effectuer cette migration, …
La nouvelle version du site naissance.fr développée s’appuie sur Symfony 2 et Elasticsearch. Cette refonte propose un tunnel d’achat spécialement développé pour l’application. Aujourd’hui, le site est équipé d’une gestion d’un mode d’envoi des faire-parts différé, de modification des compositions après paiement et de prise en charge de codes promotionnels…