├── CHANGELOG.md ├── LICENSE ├── README.md ├── Transport ├── Connection.php ├── DoctrineReceivedStamp.php ├── DoctrineReceiver.php ├── DoctrineSender.php ├── DoctrineTransport.php ├── DoctrineTransportFactory.php └── PostgreSqlConnection.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add "keepalive" support 8 | 9 | 7.1 10 | --- 11 | 12 | * Use `SKIP LOCKED` in the doctrine transport for MySQL, PostgreSQL and MSSQL 13 | 14 | 5.1.0 15 | ----- 16 | 17 | * Introduced the Doctrine bridge. 18 | * Added support for PostgreSQL `LISTEN`/`NOTIFY`. 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Doctrine Messenger 2 | ================== 3 | 4 | Provides Doctrine integration for Symfony Messenger. 5 | 6 | Resources 7 | --------- 8 | 9 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 10 | * [Report issues](https://github.com/symfony/symfony/issues) and 11 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 12 | in the [main Symfony repository](https://github.com/symfony/symfony) 13 | -------------------------------------------------------------------------------- /Transport/Connection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Connection as DBALConnection; 15 | use Doctrine\DBAL\Driver\Exception as DriverException; 16 | use Doctrine\DBAL\Exception as DBALException; 17 | use Doctrine\DBAL\Exception\TableNotFoundException; 18 | use Doctrine\DBAL\LockMode; 19 | use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; 20 | use Doctrine\DBAL\Platforms\OraclePlatform; 21 | use Doctrine\DBAL\Platforms\PostgreSQLPlatform; 22 | use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; 23 | use Doctrine\DBAL\Query\QueryBuilder; 24 | use Doctrine\DBAL\Result; 25 | use Doctrine\DBAL\Schema\AbstractAsset; 26 | use Doctrine\DBAL\Schema\Name\Identifier; 27 | use Doctrine\DBAL\Schema\Name\UnqualifiedName; 28 | use Doctrine\DBAL\Schema\PrimaryKeyConstraint; 29 | use Doctrine\DBAL\Schema\Schema; 30 | use Doctrine\DBAL\Schema\Table; 31 | use Doctrine\DBAL\Types\Types; 32 | use Symfony\Component\Messenger\Exception\InvalidArgumentException; 33 | use Symfony\Component\Messenger\Exception\TransportException; 34 | use Symfony\Contracts\Service\ResetInterface; 35 | 36 | /** 37 | * @internal 38 | * 39 | * @author Vincent Touzet 40 | * @author Kévin Dunglas 41 | * @author Herberto Graca 42 | * @author Alexander Malyk 43 | */ 44 | class Connection implements ResetInterface 45 | { 46 | private const ORACLE_SEQUENCES_SUFFIX = '_seq'; 47 | protected const TABLE_OPTION_NAME = '_symfony_messenger_table_name'; 48 | 49 | protected const DEFAULT_OPTIONS = [ 50 | 'table_name' => 'messenger_messages', 51 | 'queue_name' => 'default', 52 | 'redeliver_timeout' => 3600, 53 | 'auto_setup' => true, 54 | ]; 55 | 56 | protected ?float $queueEmptiedAt = null; 57 | 58 | private bool $autoSetup; 59 | private bool $doMysqlCleanup = false; 60 | 61 | /** 62 | * Constructor. 63 | * 64 | * Available options: 65 | * 66 | * * table_name: name of the table 67 | * * connection: name of the Doctrine's entity manager 68 | * * queue_name: name of the queue 69 | * * redeliver_timeout: Timeout before redeliver messages still in handling state (i.e: delivered_at is not null and message is still in table). Default: 3600 70 | * * auto_setup: Whether the table should be created automatically during send / get. Default: true 71 | */ 72 | public function __construct( 73 | protected array $configuration, 74 | protected DBALConnection $driverConnection, 75 | ) { 76 | $this->configuration = array_replace_recursive(static::DEFAULT_OPTIONS, $configuration); 77 | $this->autoSetup = $this->configuration['auto_setup']; 78 | } 79 | 80 | public function reset(): void 81 | { 82 | $this->queueEmptiedAt = null; 83 | $this->doMysqlCleanup = false; 84 | } 85 | 86 | public function getConfiguration(): array 87 | { 88 | return $this->configuration; 89 | } 90 | 91 | public static function buildConfiguration(#[\SensitiveParameter] string $dsn, array $options = []): array 92 | { 93 | if (false === $params = parse_url($dsn)) { 94 | throw new InvalidArgumentException('The given Doctrine Messenger DSN is invalid.'); 95 | } 96 | 97 | $query = []; 98 | if (isset($params['query'])) { 99 | parse_str($params['query'], $query); 100 | } 101 | 102 | $configuration = ['connection' => $params['host']]; 103 | $configuration += $query + $options + static::DEFAULT_OPTIONS; 104 | 105 | $configuration['auto_setup'] = filter_var($configuration['auto_setup'], \FILTER_VALIDATE_BOOL); 106 | 107 | // check for extra keys in options 108 | $optionsExtraKeys = array_diff(array_keys($options), array_keys(static::DEFAULT_OPTIONS)); 109 | if (0 < \count($optionsExtraKeys)) { 110 | throw new InvalidArgumentException(\sprintf('Unknown option found: [%s]. Allowed options are [%s].', implode(', ', $optionsExtraKeys), implode(', ', array_keys(static::DEFAULT_OPTIONS)))); 111 | } 112 | 113 | // check for extra keys in options 114 | $queryExtraKeys = array_diff(array_keys($query), array_keys(static::DEFAULT_OPTIONS)); 115 | if (0 < \count($queryExtraKeys)) { 116 | throw new InvalidArgumentException(\sprintf('Unknown option found in DSN: [%s]. Allowed options are [%s].', implode(', ', $queryExtraKeys), implode(', ', array_keys(static::DEFAULT_OPTIONS)))); 117 | } 118 | 119 | return $configuration; 120 | } 121 | 122 | /** 123 | * @param int $delay The delay in milliseconds 124 | * 125 | * @return string The inserted id 126 | * 127 | * @throws DBALException 128 | */ 129 | public function send(string $body, array $headers, int $delay = 0): string 130 | { 131 | $now = new \DateTimeImmutable('UTC'); 132 | $availableAt = $now->modify(\sprintf('%+d seconds', $delay / 1000)); 133 | 134 | $queryBuilder = $this->driverConnection->createQueryBuilder() 135 | ->insert($this->configuration['table_name']) 136 | ->values([ 137 | 'body' => '?', 138 | 'headers' => '?', 139 | 'queue_name' => '?', 140 | 'created_at' => '?', 141 | 'available_at' => '?', 142 | ]); 143 | 144 | return $this->executeInsert($queryBuilder->getSQL(), [ 145 | $body, 146 | json_encode($headers), 147 | $this->configuration['queue_name'], 148 | $now, 149 | $availableAt, 150 | ], [ 151 | Types::STRING, 152 | Types::STRING, 153 | Types::STRING, 154 | Types::DATETIME_IMMUTABLE, 155 | Types::DATETIME_IMMUTABLE, 156 | ]); 157 | } 158 | 159 | public function get(): ?array 160 | { 161 | if ($this->doMysqlCleanup && $this->driverConnection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { 162 | try { 163 | $this->driverConnection->delete($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59']); 164 | $this->doMysqlCleanup = false; 165 | } catch (DriverException $e) { 166 | // Ignore the exception 167 | } catch (TableNotFoundException $e) { 168 | if ($this->autoSetup) { 169 | $this->setup(); 170 | } 171 | } 172 | } 173 | 174 | get: 175 | $this->driverConnection->beginTransaction(); 176 | try { 177 | $query = $this->createAvailableMessagesQueryBuilder() 178 | ->orderBy('available_at', 'ASC') 179 | ->setMaxResults(1); 180 | 181 | if ($this->driverConnection->getDatabasePlatform() instanceof OraclePlatform) { 182 | $query->select('m.id'); 183 | } 184 | 185 | // Append pessimistic write lock to FROM clause if db platform supports it 186 | $sql = $query->getSQL(); 187 | 188 | // Wrap the rownum query in a sub-query to allow writelocks without ORA-02014 error 189 | if ($this->driverConnection->getDatabasePlatform() instanceof OraclePlatform) { 190 | $query = $this->createQueryBuilder('w') 191 | ->where('w.id IN ('.str_replace('SELECT a.* FROM', 'SELECT a.id FROM', $sql).')') 192 | ->setParameters($query->getParameters(), $query->getParameterTypes()); 193 | 194 | $sql = $query->getSQL(); 195 | } 196 | 197 | if (method_exists(QueryBuilder::class, 'forUpdate')) { 198 | $sql = $this->addLockMode($query, $sql); 199 | } else { 200 | if (preg_match('/FROM (.+) WHERE/', $sql, $matches)) { 201 | $fromClause = $matches[1]; 202 | $sql = str_replace( 203 | \sprintf('FROM %s WHERE', $fromClause), 204 | \sprintf('FROM %s WHERE', $this->driverConnection->getDatabasePlatform()->appendLockHint($fromClause, LockMode::PESSIMISTIC_WRITE)), 205 | $sql 206 | ); 207 | } 208 | 209 | // use SELECT ... FOR UPDATE to lock table 210 | $sql .= ' '.$this->driverConnection->getDatabasePlatform()->getWriteLockSQL(); 211 | } 212 | 213 | $doctrineEnvelope = $this->executeQuery( 214 | $sql, 215 | $query->getParameters(), 216 | $query->getParameterTypes() 217 | )->fetchAssociative(); 218 | 219 | if (false === $doctrineEnvelope) { 220 | $this->driverConnection->commit(); 221 | $this->queueEmptiedAt = microtime(true) * 1000; 222 | 223 | return null; 224 | } 225 | // Postgres can "group" notifications having the same channel and payload 226 | // We need to be sure to empty the queue before blocking again 227 | $this->queueEmptiedAt = null; 228 | 229 | $doctrineEnvelope = $this->decodeEnvelopeHeaders($doctrineEnvelope); 230 | 231 | $queryBuilder = $this->driverConnection->createQueryBuilder() 232 | ->update($this->configuration['table_name']) 233 | ->set('delivered_at', '?') 234 | ->where('id = ?'); 235 | $now = new \DateTimeImmutable('UTC'); 236 | $this->executeStatement($queryBuilder->getSQL(), [ 237 | $now, 238 | $doctrineEnvelope['id'], 239 | ], [ 240 | Types::DATETIME_IMMUTABLE, 241 | ]); 242 | 243 | $this->driverConnection->commit(); 244 | 245 | return $doctrineEnvelope; 246 | } catch (\Throwable $e) { 247 | $this->driverConnection->rollBack(); 248 | 249 | if ($this->autoSetup && $e instanceof TableNotFoundException) { 250 | $this->setup(); 251 | goto get; 252 | } 253 | 254 | throw $e; 255 | } 256 | } 257 | 258 | public function ack(string $id): bool 259 | { 260 | try { 261 | if ($this->driverConnection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { 262 | if ($updated = $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59'], ['id' => $id]) > 0) { 263 | $this->doMysqlCleanup = true; 264 | } 265 | 266 | return $updated; 267 | } 268 | 269 | return $this->driverConnection->delete($this->configuration['table_name'], ['id' => $id]) > 0; 270 | } catch (DBALException $exception) { 271 | throw new TransportException($exception->getMessage(), 0, $exception); 272 | } 273 | } 274 | 275 | public function reject(string $id): bool 276 | { 277 | try { 278 | if ($this->driverConnection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { 279 | if ($updated = $this->driverConnection->update($this->configuration['table_name'], ['delivered_at' => '9999-12-31 23:59:59'], ['id' => $id]) > 0) { 280 | $this->doMysqlCleanup = true; 281 | } 282 | 283 | return $updated; 284 | } 285 | 286 | return $this->driverConnection->delete($this->configuration['table_name'], ['id' => $id]) > 0; 287 | } catch (DBALException $exception) { 288 | throw new TransportException($exception->getMessage(), 0, $exception); 289 | } 290 | } 291 | 292 | public function keepalive(string $id, ?int $seconds = null): void 293 | { 294 | // Check if the redeliver timeout is smaller than the keepalive interval 295 | if (null !== $seconds && $this->configuration['redeliver_timeout'] < $seconds) { 296 | throw new TransportException(\sprintf('Doctrine redeliver_timeout (%ds) cannot be smaller than the keepalive interval (%ds).', $this->configuration['redeliver_timeout'], $seconds)); 297 | } 298 | 299 | $this->driverConnection->beginTransaction(); 300 | try { 301 | $queryBuilder = $this->driverConnection->createQueryBuilder() 302 | ->update($this->configuration['table_name']) 303 | ->set('delivered_at', '?') 304 | ->where('id = ?'); 305 | $now = new \DateTimeImmutable('UTC'); 306 | $this->executeStatement($queryBuilder->getSQL(), [ 307 | $now, 308 | $id, 309 | ], [ 310 | Types::DATETIME_IMMUTABLE, 311 | ]); 312 | 313 | $this->driverConnection->commit(); 314 | } catch (\Throwable $e) { 315 | $this->driverConnection->rollBack(); 316 | throw new TransportException($e->getMessage(), 0, $e); 317 | } 318 | } 319 | 320 | public function setup(): void 321 | { 322 | $configuration = $this->driverConnection->getConfiguration(); 323 | $assetFilter = $configuration->getSchemaAssetsFilter(); 324 | $configuration->setSchemaAssetsFilter(function ($tableName) { 325 | if ($tableName instanceof AbstractAsset) { 326 | $tableName = $tableName->getName(); 327 | } 328 | 329 | if (!\is_string($tableName)) { 330 | throw new \TypeError(\sprintf('The table name must be an instance of "%s" or a string ("%s" given).', AbstractAsset::class, get_debug_type($tableName))); 331 | } 332 | 333 | return $tableName === $this->configuration['table_name']; 334 | }); 335 | $this->updateSchema(); 336 | $configuration->setSchemaAssetsFilter($assetFilter); 337 | $this->autoSetup = false; 338 | } 339 | 340 | public function getMessageCount(): int 341 | { 342 | $queryBuilder = $this->createAvailableMessagesQueryBuilder() 343 | ->select('COUNT(m.id) AS message_count') 344 | ->setMaxResults(1); 345 | 346 | return $this->executeQuery($queryBuilder->getSQL(), $queryBuilder->getParameters(), $queryBuilder->getParameterTypes())->fetchOne(); 347 | } 348 | 349 | public function findAll(?int $limit = null): array 350 | { 351 | $queryBuilder = $this->createAvailableMessagesQueryBuilder(); 352 | 353 | if (null !== $limit) { 354 | $queryBuilder->setMaxResults($limit); 355 | } 356 | 357 | return array_map( 358 | $this->decodeEnvelopeHeaders(...), 359 | $this->executeQuery($queryBuilder->getSQL(), $queryBuilder->getParameters(), $queryBuilder->getParameterTypes())->fetchAllAssociative() 360 | ); 361 | } 362 | 363 | public function find(mixed $id): ?array 364 | { 365 | $queryBuilder = $this->createQueryBuilder() 366 | ->where('m.id = ? and m.queue_name = ?'); 367 | 368 | $data = $this->executeQuery($queryBuilder->getSQL(), [$id, $this->configuration['queue_name']])->fetchAssociative(); 369 | 370 | return false === $data ? null : $this->decodeEnvelopeHeaders($data); 371 | } 372 | 373 | /** 374 | * @internal 375 | */ 376 | public function configureSchema(Schema $schema, DBALConnection $forConnection, \Closure $isSameDatabase): void 377 | { 378 | if ($schema->hasTable($this->configuration['table_name'])) { 379 | return; 380 | } 381 | 382 | if ($forConnection !== $this->driverConnection && !$isSameDatabase($this->executeStatement(...))) { 383 | return; 384 | } 385 | 386 | $this->addTableToSchema($schema); 387 | } 388 | 389 | /** 390 | * @internal 391 | */ 392 | public function getExtraSetupSqlForTable(Table $createdTable): array 393 | { 394 | return []; 395 | } 396 | 397 | private function createAvailableMessagesQueryBuilder(): QueryBuilder 398 | { 399 | $now = new \DateTimeImmutable('UTC'); 400 | $redeliverLimit = $now->modify(\sprintf('-%d seconds', $this->configuration['redeliver_timeout'])); 401 | 402 | return $this->createQueryBuilder() 403 | ->where('m.queue_name = ?') 404 | ->andWhere('m.delivered_at is null OR m.delivered_at < ?') 405 | ->andWhere('m.available_at <= ?') 406 | ->setParameters([ 407 | $this->configuration['queue_name'], 408 | $redeliverLimit, 409 | $now, 410 | ], [ 411 | Types::STRING, 412 | Types::DATETIME_IMMUTABLE, 413 | Types::DATETIME_IMMUTABLE, 414 | ]); 415 | } 416 | 417 | private function createQueryBuilder(string $alias = 'm'): QueryBuilder 418 | { 419 | $queryBuilder = $this->driverConnection->createQueryBuilder() 420 | ->from($this->configuration['table_name'], $alias); 421 | 422 | $alias .= '.'; 423 | 424 | if (!$this->driverConnection->getDatabasePlatform() instanceof OraclePlatform) { 425 | return $queryBuilder->select($alias.'*'); 426 | } 427 | 428 | // Oracle databases use UPPER CASE on tables and column identifiers. 429 | // Column alias is added to force the result to be lowercase even when the actual field is all caps. 430 | 431 | return $queryBuilder->select(str_replace(', ', ', '.$alias, 432 | $alias.'id AS "id", body AS "body", headers AS "headers", queue_name AS "queue_name", '. 433 | 'created_at AS "created_at", available_at AS "available_at", '. 434 | 'delivered_at AS "delivered_at"' 435 | )); 436 | } 437 | 438 | private function executeQuery(string $sql, array $parameters = [], array $types = []): Result 439 | { 440 | try { 441 | return $this->driverConnection->executeQuery($sql, $parameters, $types); 442 | } catch (TableNotFoundException $e) { 443 | if (!$this->autoSetup || $this->driverConnection->isTransactionActive()) { 444 | throw $e; 445 | } 446 | } 447 | 448 | $this->setup(); 449 | 450 | return $this->driverConnection->executeQuery($sql, $parameters, $types); 451 | } 452 | 453 | protected function executeStatement(string $sql, array $parameters = [], array $types = []): int|string 454 | { 455 | try { 456 | return $this->driverConnection->executeStatement($sql, $parameters, $types); 457 | } catch (TableNotFoundException $e) { 458 | if (!$this->autoSetup || $this->driverConnection->isTransactionActive()) { 459 | throw $e; 460 | } 461 | } 462 | 463 | $this->setup(); 464 | 465 | return $this->driverConnection->executeStatement($sql, $parameters, $types); 466 | } 467 | 468 | private function executeInsert(string $sql, array $parameters = [], array $types = []): string 469 | { 470 | // Use PostgreSQL RETURNING clause instead of lastInsertId() to get the 471 | // inserted id in one operation instead of two. 472 | if ($this->driverConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) { 473 | $sql .= ' RETURNING id'; 474 | } 475 | 476 | insert: 477 | $this->driverConnection->beginTransaction(); 478 | 479 | try { 480 | if ($this->driverConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) { 481 | $first = $this->driverConnection->fetchFirstColumn($sql, $parameters, $types); 482 | 483 | $id = $first[0] ?? null; 484 | 485 | if (!$id) { 486 | throw new TransportException('no id was returned by PostgreSQL from RETURNING clause.'); 487 | } 488 | } elseif ($this->driverConnection->getDatabasePlatform() instanceof OraclePlatform) { 489 | $sequenceName = $this->configuration['table_name'].self::ORACLE_SEQUENCES_SUFFIX; 490 | 491 | $this->driverConnection->executeStatement($sql, $parameters, $types); 492 | 493 | $result = $this->driverConnection->fetchOne('SELECT '.$sequenceName.'.CURRVAL FROM DUAL'); 494 | 495 | $id = (int) $result; 496 | 497 | if (!$id) { 498 | throw new TransportException('no id was returned by Oracle from sequence: '.$sequenceName); 499 | } 500 | } else { 501 | $this->driverConnection->executeStatement($sql, $parameters, $types); 502 | 503 | if (!$id = $this->driverConnection->lastInsertId()) { 504 | throw new TransportException('lastInsertId() returned false, no id was returned.'); 505 | } 506 | } 507 | 508 | $this->driverConnection->commit(); 509 | } catch (\Throwable $e) { 510 | $this->driverConnection->rollBack(); 511 | 512 | // handle setup after transaction is no longer open 513 | if ($this->autoSetup && $e instanceof TableNotFoundException) { 514 | $this->setup(); 515 | goto insert; 516 | } 517 | 518 | throw $e; 519 | } 520 | 521 | return $id; 522 | } 523 | 524 | private function getSchema(): Schema 525 | { 526 | $schema = new Schema([], [], $this->driverConnection->createSchemaManager()->createSchemaConfig()); 527 | $this->addTableToSchema($schema); 528 | 529 | return $schema; 530 | } 531 | 532 | private function addTableToSchema(Schema $schema): void 533 | { 534 | $table = $schema->createTable($this->configuration['table_name']); 535 | // add an internal option to mark that we created this & the non-namespaced table name 536 | $table->addOption(self::TABLE_OPTION_NAME, $this->configuration['table_name']); 537 | $idColumn = $table->addColumn('id', Types::BIGINT) 538 | ->setAutoincrement(true) 539 | ->setNotnull(true); 540 | $table->addColumn('body', Types::TEXT) 541 | ->setNotnull(true); 542 | $table->addColumn('headers', Types::TEXT) 543 | ->setNotnull(true); 544 | $table->addColumn('queue_name', Types::STRING) 545 | ->setLength(190) // MySQL 5.6 only supports 191 characters on an indexed column in utf8mb4 mode 546 | ->setNotnull(true); 547 | $table->addColumn('created_at', Types::DATETIME_IMMUTABLE) 548 | ->setNotnull(true); 549 | $table->addColumn('available_at', Types::DATETIME_IMMUTABLE) 550 | ->setNotnull(true); 551 | $table->addColumn('delivered_at', Types::DATETIME_IMMUTABLE) 552 | ->setNotnull(false); 553 | if (class_exists(PrimaryKeyConstraint::class)) { 554 | $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted('id'))], true)); 555 | } else { 556 | $table->setPrimaryKey(['id']); 557 | } 558 | $table->addIndex(['queue_name']); 559 | $table->addIndex(['available_at']); 560 | $table->addIndex(['delivered_at']); 561 | 562 | // We need to create a sequence for Oracle and set the id column to get the correct nextval 563 | if ($this->driverConnection->getDatabasePlatform() instanceof OraclePlatform) { 564 | $idColumn->setDefault($this->configuration['table_name'].self::ORACLE_SEQUENCES_SUFFIX.'.nextval'); 565 | 566 | $schema->createSequence($this->configuration['table_name'].self::ORACLE_SEQUENCES_SUFFIX); 567 | } 568 | } 569 | 570 | private function decodeEnvelopeHeaders(array $doctrineEnvelope): array 571 | { 572 | $doctrineEnvelope['headers'] = json_decode($doctrineEnvelope['headers'], true); 573 | 574 | return $doctrineEnvelope; 575 | } 576 | 577 | private function updateSchema(): void 578 | { 579 | $schemaManager = $this->driverConnection->createSchemaManager(); 580 | $schemaDiff = $schemaManager->createComparator() 581 | ->compareSchemas($schemaManager->introspectSchema(), $this->getSchema()); 582 | $platform = $this->driverConnection->getDatabasePlatform(); 583 | 584 | if ($platform->supportsSchemas()) { 585 | foreach ($schemaDiff->getCreatedSchemas() as $schema) { 586 | $this->driverConnection->executeStatement($platform->getCreateSchemaSQL($schema)); 587 | } 588 | } 589 | 590 | if ($platform->supportsSequences()) { 591 | foreach ($schemaDiff->getAlteredSequences() as $sequence) { 592 | $this->driverConnection->executeStatement($platform->getAlterSequenceSQL($sequence)); 593 | } 594 | 595 | foreach ($schemaDiff->getCreatedSequences() as $sequence) { 596 | $this->driverConnection->executeStatement($platform->getCreateSequenceSQL($sequence)); 597 | } 598 | } 599 | 600 | foreach ($platform->getCreateTablesSQL($schemaDiff->getCreatedTables()) as $sql) { 601 | $this->driverConnection->executeStatement($sql); 602 | } 603 | 604 | foreach ($schemaDiff->getAlteredTables() as $tableDiff) { 605 | foreach ($platform->getAlterTableSQL($tableDiff) as $sql) { 606 | $this->driverConnection->executeStatement($sql); 607 | } 608 | } 609 | } 610 | 611 | private function addLockMode(QueryBuilder $query, string $sql): string 612 | { 613 | $query->forUpdate(ConflictResolutionMode::SKIP_LOCKED); 614 | try { 615 | return $query->getSQL(); 616 | } catch (DBALException) { 617 | return $this->fallBackToForUpdate($query, $sql); 618 | } 619 | } 620 | 621 | private function fallBackToForUpdate(QueryBuilder $query, string $sql): string 622 | { 623 | $query->forUpdate(); 624 | try { 625 | return $query->getSQL(); 626 | } catch (DBALException) { 627 | return $sql; 628 | } 629 | } 630 | } 631 | -------------------------------------------------------------------------------- /Transport/DoctrineReceivedStamp.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Symfony\Component\Messenger\Stamp\NonSendableStampInterface; 15 | 16 | /** 17 | * @author Vincent Touzet 18 | */ 19 | class DoctrineReceivedStamp implements NonSendableStampInterface 20 | { 21 | public function __construct( 22 | private string $id, 23 | ) { 24 | } 25 | 26 | public function getId(): string 27 | { 28 | return $this->id; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Transport/DoctrineReceiver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Exception as DBALException; 15 | use Doctrine\DBAL\Exception\RetryableException; 16 | use Symfony\Component\Messenger\Envelope; 17 | use Symfony\Component\Messenger\Exception\LogicException; 18 | use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; 19 | use Symfony\Component\Messenger\Exception\TransportException; 20 | use Symfony\Component\Messenger\Stamp\TransportMessageIdStamp; 21 | use Symfony\Component\Messenger\Transport\Receiver\KeepaliveReceiverInterface; 22 | use Symfony\Component\Messenger\Transport\Receiver\ListableReceiverInterface; 23 | use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; 24 | use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; 25 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 26 | 27 | /** 28 | * @author Vincent Touzet 29 | */ 30 | class DoctrineReceiver implements ListableReceiverInterface, MessageCountAwareInterface, KeepaliveReceiverInterface 31 | { 32 | private const MAX_RETRIES = 3; 33 | private int $retryingSafetyCounter = 0; 34 | private SerializerInterface $serializer; 35 | 36 | public function __construct( 37 | private Connection $connection, 38 | ?SerializerInterface $serializer = null, 39 | ) { 40 | $this->serializer = $serializer ?? new PhpSerializer(); 41 | } 42 | 43 | public function get(): iterable 44 | { 45 | try { 46 | $doctrineEnvelope = $this->connection->get(); 47 | $this->retryingSafetyCounter = 0; // reset counter 48 | } catch (RetryableException $exception) { 49 | // Do nothing when RetryableException occurs less than "MAX_RETRIES" 50 | // as it will likely be resolved on the next call to get() 51 | // Problem with concurrent consumers and database deadlocks 52 | if (++$this->retryingSafetyCounter >= self::MAX_RETRIES) { 53 | $this->retryingSafetyCounter = 0; // reset counter 54 | throw new TransportException($exception->getMessage(), 0, $exception); 55 | } 56 | 57 | return []; 58 | } catch (DBALException $exception) { 59 | throw new TransportException($exception->getMessage(), 0, $exception); 60 | } 61 | 62 | if (null === $doctrineEnvelope) { 63 | return []; 64 | } 65 | 66 | return [$this->createEnvelopeFromData($doctrineEnvelope)]; 67 | } 68 | 69 | public function ack(Envelope $envelope): void 70 | { 71 | $this->withRetryableExceptionRetry(function () use ($envelope) { 72 | $this->connection->ack($this->findDoctrineReceivedStamp($envelope)->getId()); 73 | }); 74 | } 75 | 76 | public function keepalive(Envelope $envelope, ?int $seconds = null): void 77 | { 78 | $this->connection->keepalive($this->findDoctrineReceivedStamp($envelope)->getId(), $seconds); 79 | } 80 | 81 | public function reject(Envelope $envelope): void 82 | { 83 | $this->withRetryableExceptionRetry(function () use ($envelope) { 84 | $this->connection->reject($this->findDoctrineReceivedStamp($envelope)->getId()); 85 | }); 86 | } 87 | 88 | public function getMessageCount(): int 89 | { 90 | try { 91 | return $this->connection->getMessageCount(); 92 | } catch (DBALException $exception) { 93 | throw new TransportException($exception->getMessage(), 0, $exception); 94 | } 95 | } 96 | 97 | public function all(?int $limit = null): iterable 98 | { 99 | try { 100 | $doctrineEnvelopes = $this->connection->findAll($limit); 101 | } catch (DBALException $exception) { 102 | throw new TransportException($exception->getMessage(), 0, $exception); 103 | } 104 | 105 | foreach ($doctrineEnvelopes as $doctrineEnvelope) { 106 | yield $this->createEnvelopeFromData($doctrineEnvelope); 107 | } 108 | } 109 | 110 | public function find(mixed $id): ?Envelope 111 | { 112 | try { 113 | $doctrineEnvelope = $this->connection->find($id); 114 | } catch (DBALException $exception) { 115 | throw new TransportException($exception->getMessage(), 0, $exception); 116 | } 117 | 118 | if (null === $doctrineEnvelope) { 119 | return null; 120 | } 121 | 122 | return $this->createEnvelopeFromData($doctrineEnvelope); 123 | } 124 | 125 | private function findDoctrineReceivedStamp(Envelope $envelope): DoctrineReceivedStamp 126 | { 127 | /** @var DoctrineReceivedStamp|null $doctrineReceivedStamp */ 128 | $doctrineReceivedStamp = $envelope->last(DoctrineReceivedStamp::class); 129 | 130 | if (null === $doctrineReceivedStamp) { 131 | throw new LogicException('No DoctrineReceivedStamp found on the Envelope.'); 132 | } 133 | 134 | return $doctrineReceivedStamp; 135 | } 136 | 137 | private function createEnvelopeFromData(array $data): Envelope 138 | { 139 | try { 140 | $envelope = $this->serializer->decode([ 141 | 'body' => $data['body'], 142 | 'headers' => $data['headers'], 143 | ]); 144 | } catch (MessageDecodingFailedException $exception) { 145 | $this->connection->reject($data['id']); 146 | 147 | throw $exception; 148 | } 149 | 150 | return $envelope 151 | ->withoutAll(TransportMessageIdStamp::class) 152 | ->with( 153 | new DoctrineReceivedStamp($data['id']), 154 | new TransportMessageIdStamp($data['id']) 155 | ); 156 | } 157 | 158 | private function withRetryableExceptionRetry(callable $callable): void 159 | { 160 | $delay = 100; 161 | $multiplier = 2; 162 | $jitter = 0.1; 163 | $retries = 0; 164 | 165 | retry: 166 | try { 167 | $callable(); 168 | } catch (RetryableException $exception) { 169 | if (++$retries <= self::MAX_RETRIES) { 170 | $delay *= $multiplier; 171 | 172 | $randomness = (int) ($delay * $jitter); 173 | $delay += random_int(-$randomness, +$randomness); 174 | 175 | usleep($delay * 1000); 176 | 177 | goto retry; 178 | } 179 | 180 | throw new TransportException($exception->getMessage(), 0, $exception); 181 | } catch (DBALException $exception) { 182 | throw new TransportException($exception->getMessage(), 0, $exception); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /Transport/DoctrineSender.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Exception as DBALException; 15 | use Symfony\Component\Messenger\Envelope; 16 | use Symfony\Component\Messenger\Exception\TransportException; 17 | use Symfony\Component\Messenger\Stamp\DelayStamp; 18 | use Symfony\Component\Messenger\Stamp\TransportMessageIdStamp; 19 | use Symfony\Component\Messenger\Transport\Sender\SenderInterface; 20 | use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer; 21 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 22 | 23 | /** 24 | * @author Vincent Touzet 25 | */ 26 | class DoctrineSender implements SenderInterface 27 | { 28 | private SerializerInterface $serializer; 29 | 30 | public function __construct( 31 | private Connection $connection, 32 | ?SerializerInterface $serializer = null, 33 | ) { 34 | $this->serializer = $serializer ?? new PhpSerializer(); 35 | } 36 | 37 | public function send(Envelope $envelope): Envelope 38 | { 39 | $encodedMessage = $this->serializer->encode($envelope); 40 | 41 | /** @var DelayStamp|null $delayStamp */ 42 | $delayStamp = $envelope->last(DelayStamp::class); 43 | $delay = null !== $delayStamp ? $delayStamp->getDelay() : 0; 44 | 45 | try { 46 | $id = $this->connection->send($encodedMessage['body'], $encodedMessage['headers'] ?? [], $delay); 47 | } catch (DBALException $exception) { 48 | throw new TransportException($exception->getMessage(), 0, $exception); 49 | } 50 | 51 | return $envelope->with(new TransportMessageIdStamp($id)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Transport/DoctrineTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Connection as DbalConnection; 15 | use Doctrine\DBAL\Schema\Schema; 16 | use Doctrine\DBAL\Schema\Table; 17 | use Symfony\Component\Messenger\Envelope; 18 | use Symfony\Component\Messenger\Transport\Receiver\KeepaliveReceiverInterface; 19 | use Symfony\Component\Messenger\Transport\Receiver\ListableReceiverInterface; 20 | use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; 21 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 22 | use Symfony\Component\Messenger\Transport\SetupableTransportInterface; 23 | use Symfony\Component\Messenger\Transport\TransportInterface; 24 | 25 | /** 26 | * @author Vincent Touzet 27 | */ 28 | class DoctrineTransport implements TransportInterface, SetupableTransportInterface, MessageCountAwareInterface, ListableReceiverInterface, KeepaliveReceiverInterface 29 | { 30 | private DoctrineReceiver $receiver; 31 | private DoctrineSender $sender; 32 | 33 | public function __construct( 34 | private Connection $connection, 35 | private SerializerInterface $serializer, 36 | ) { 37 | } 38 | 39 | public function get(): iterable 40 | { 41 | return $this->getReceiver()->get(); 42 | } 43 | 44 | public function ack(Envelope $envelope): void 45 | { 46 | $this->getReceiver()->ack($envelope); 47 | } 48 | 49 | public function reject(Envelope $envelope): void 50 | { 51 | $this->getReceiver()->reject($envelope); 52 | } 53 | 54 | public function keepalive(Envelope $envelope, ?int $seconds = null): void 55 | { 56 | $this->getReceiver()->keepalive($envelope, $seconds); 57 | } 58 | 59 | public function getMessageCount(): int 60 | { 61 | return $this->getReceiver()->getMessageCount(); 62 | } 63 | 64 | public function all(?int $limit = null): iterable 65 | { 66 | return $this->getReceiver()->all($limit); 67 | } 68 | 69 | public function find(mixed $id): ?Envelope 70 | { 71 | return $this->getReceiver()->find($id); 72 | } 73 | 74 | public function send(Envelope $envelope): Envelope 75 | { 76 | return $this->getSender()->send($envelope); 77 | } 78 | 79 | public function setup(): void 80 | { 81 | $this->connection->setup(); 82 | } 83 | 84 | /** 85 | * Adds the Table to the Schema if this transport uses this connection. 86 | */ 87 | public function configureSchema(Schema $schema, DbalConnection $forConnection, \Closure $isSameDatabase): void 88 | { 89 | $this->connection->configureSchema($schema, $forConnection, $isSameDatabase); 90 | } 91 | 92 | /** 93 | * Adds extra SQL if the given table was created by the Connection. 94 | * 95 | * @return string[] 96 | */ 97 | public function getExtraSetupSqlForTable(Table $createdTable): array 98 | { 99 | return $this->connection->getExtraSetupSqlForTable($createdTable); 100 | } 101 | 102 | private function getReceiver(): DoctrineReceiver 103 | { 104 | return $this->receiver ??= new DoctrineReceiver($this->connection, $this->serializer); 105 | } 106 | 107 | private function getSender(): DoctrineSender 108 | { 109 | return $this->sender ??= new DoctrineSender($this->connection, $this->serializer); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Transport/DoctrineTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Platforms\PostgreSQLPlatform; 15 | use Doctrine\Persistence\ConnectionRegistry; 16 | use Symfony\Component\Messenger\Exception\TransportException; 17 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 18 | use Symfony\Component\Messenger\Transport\TransportFactoryInterface; 19 | use Symfony\Component\Messenger\Transport\TransportInterface; 20 | 21 | /** 22 | * @author Vincent Touzet 23 | * 24 | * @implements TransportFactoryInterface 25 | */ 26 | class DoctrineTransportFactory implements TransportFactoryInterface 27 | { 28 | public function __construct( 29 | private ConnectionRegistry $registry, 30 | ) { 31 | } 32 | 33 | /** 34 | * @param array $options You can set 'use_notify' to false to not use LISTEN/NOTIFY with postgresql 35 | */ 36 | public function createTransport(#[\SensitiveParameter] string $dsn, array $options, SerializerInterface $serializer): TransportInterface 37 | { 38 | $useNotify = ($options['use_notify'] ?? true); 39 | unset($options['transport_name'], $options['use_notify']); 40 | // Always allow PostgreSQL-specific keys, to be able to transparently fallback to the native driver when LISTEN/NOTIFY isn't available 41 | $configuration = PostgreSqlConnection::buildConfiguration($dsn, $options); 42 | 43 | try { 44 | $driverConnection = $this->registry->getConnection($configuration['connection']); 45 | } catch (\InvalidArgumentException $e) { 46 | throw new TransportException('Could not find Doctrine connection from Messenger DSN.', 0, $e); 47 | } 48 | 49 | if ($useNotify && $driverConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) { 50 | $connection = new PostgreSqlConnection($configuration, $driverConnection); 51 | } else { 52 | $connection = new Connection($configuration, $driverConnection); 53 | } 54 | 55 | return new DoctrineTransport($connection, $serializer); 56 | } 57 | 58 | public function supports(#[\SensitiveParameter] string $dsn, array $options): bool 59 | { 60 | return str_starts_with($dsn, 'doctrine://'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Transport/PostgreSqlConnection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Messenger\Bridge\Doctrine\Transport; 13 | 14 | use Doctrine\DBAL\Schema\Table; 15 | 16 | /** 17 | * Uses PostgreSQL LISTEN/NOTIFY to push messages to workers. 18 | * 19 | * If you do not want to use the LISTEN mechanism, set the `use_notify` option to `false` when calling DoctrineTransportFactory::createTransport. 20 | * 21 | * @internal 22 | * 23 | * @author Kévin Dunglas 24 | */ 25 | final class PostgreSqlConnection extends Connection 26 | { 27 | /** 28 | * * check_delayed_interval: The interval to check for delayed messages, in milliseconds. Set to 0 to disable checks. Default: 60000 (1 minute) 29 | * * get_notify_timeout: The length of time to wait for a response when calling PDO::pgsqlGetNotify, in milliseconds. Default: 0. 30 | */ 31 | protected const DEFAULT_OPTIONS = parent::DEFAULT_OPTIONS + [ 32 | 'check_delayed_interval' => 60000, 33 | 'get_notify_timeout' => 0, 34 | ]; 35 | 36 | public function __sleep(): array 37 | { 38 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 39 | } 40 | 41 | public function __wakeup(): void 42 | { 43 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 44 | } 45 | 46 | public function __destruct() 47 | { 48 | $this->unlisten(); 49 | } 50 | 51 | public function reset(): void 52 | { 53 | parent::reset(); 54 | $this->unlisten(); 55 | } 56 | 57 | public function get(): ?array 58 | { 59 | if (null === $this->queueEmptiedAt) { 60 | return parent::get(); 61 | } 62 | 63 | // This is secure because the table name must be a valid identifier: 64 | // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS 65 | $this->executeStatement(\sprintf('LISTEN "%s"', $this->configuration['table_name'])); 66 | 67 | /** @var \PDO $nativeConnection */ 68 | $nativeConnection = $this->driverConnection->getNativeConnection(); 69 | 70 | $notification = $nativeConnection->pgsqlGetNotify(\PDO::FETCH_ASSOC, $this->configuration['get_notify_timeout']); 71 | if ( 72 | // no notifications, or for another table or queue 73 | (false === $notification || $notification['message'] !== $this->configuration['table_name'] || $notification['payload'] !== $this->configuration['queue_name']) 74 | // delayed messages 75 | && (microtime(true) * 1000 - $this->queueEmptiedAt < $this->configuration['check_delayed_interval']) 76 | ) { 77 | usleep(1000); 78 | 79 | return null; 80 | } 81 | 82 | return parent::get(); 83 | } 84 | 85 | public function setup(): void 86 | { 87 | parent::setup(); 88 | 89 | $this->executeStatement(implode("\n", $this->getTriggerSql())); 90 | } 91 | 92 | /** 93 | * @return string[] 94 | */ 95 | public function getExtraSetupSqlForTable(Table $createdTable): array 96 | { 97 | if (!$createdTable->hasOption(self::TABLE_OPTION_NAME)) { 98 | return []; 99 | } 100 | 101 | if ($createdTable->getOption(self::TABLE_OPTION_NAME) !== $this->configuration['table_name']) { 102 | return []; 103 | } 104 | 105 | return $this->getTriggerSql(); 106 | } 107 | 108 | private function getTriggerSql(): array 109 | { 110 | $functionName = $this->createTriggerFunctionName(); 111 | 112 | return [ 113 | // create trigger function 114 | \sprintf(<<<'SQL' 115 | CREATE OR REPLACE FUNCTION %1$s() RETURNS TRIGGER AS $$ 116 | BEGIN 117 | PERFORM pg_notify('%2$s', NEW.queue_name::text); 118 | RETURN NEW; 119 | END; 120 | $$ LANGUAGE plpgsql; 121 | SQL 122 | , $functionName, $this->configuration['table_name']), 123 | // register trigger 124 | \sprintf('DROP TRIGGER IF EXISTS notify_trigger ON %s;', $this->configuration['table_name']), 125 | \sprintf('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON %1$s FOR EACH ROW EXECUTE PROCEDURE %2$s();', $this->configuration['table_name'], $functionName), 126 | ]; 127 | } 128 | 129 | private function createTriggerFunctionName(): string 130 | { 131 | $tableConfig = explode('.', $this->configuration['table_name']); 132 | 133 | if (1 === \count($tableConfig)) { 134 | return \sprintf('notify_%1$s', $tableConfig[0]); 135 | } 136 | 137 | return \sprintf('%1$s.notify_%2$s', $tableConfig[0], $tableConfig[1]); 138 | } 139 | 140 | private function unlisten(): void 141 | { 142 | $this->executeStatement(\sprintf('UNLISTEN "%s"', $this->configuration['table_name'])); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/doctrine-messenger", 3 | "type": "symfony-messenger-bridge", 4 | "description": "Symfony Doctrine Messenger Bridge", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "doctrine/dbal": "^3.6|^4", 21 | "symfony/messenger": "^7.2", 22 | "symfony/service-contracts": "^2.5|^3" 23 | }, 24 | "require-dev": { 25 | "doctrine/persistence": "^1.3|^2|^3", 26 | "symfony/property-access": "^6.4|^7.0", 27 | "symfony/serializer": "^6.4|^7.0" 28 | }, 29 | "conflict": { 30 | "doctrine/persistence": "<1.3" 31 | }, 32 | "autoload": { 33 | "psr-4": { "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" }, 34 | "exclude-from-classmap": [ 35 | "/Tests/" 36 | ] 37 | }, 38 | "minimum-stability": "dev" 39 | } 40 | --------------------------------------------------------------------------------