ahmed-bhs/doctrine-doctor
Doctrine Doctor is a runtime analysis tool for Doctrine ORM that plugs into the Symfony Web Profiler. It ships 90+ analyzers to catch N+1 queries, missing indexes, slow queries, injection risks, and other performance, security, and integrity issues.
Full Changelog: https://github.com/ahmed-bhs/doctrine-doctor/compare/v2.11.1...v2.11.2
BulkInsertAnalyzer recommends DBAL for imports that insert rows one by one. The ORM writes one INSERT per persisted entity, so an import of hundreds of rows costs hundreds of round trips. When at least bulk_insert.threshold (default 100) single-row INSERT statements target the same table, the analyzer suggests a chunked multi-row INSERT through Connection::executeStatement(). For a mapped entity it lists what DBAL would bypass, read from the metadata: lifecycle callbacks, entity listeners, and identifiers generated by IDENTITY or sequences. Statements that already insert several rows, or INSERT ... SELECT, are not reported. An integration test runs it on the SQL of a real flush() of 150 entities and on the same rows written with DBAL batches.ImplicitTypeConversionAnalyzer detects integer-bound DQL parameters on text columns. DQL infers a parameter's binding type from its PHP value, so ->setParameter('code', 123) on a string column binds an integer, and MySQL and MariaDB then read every row instead of looking the value up in the index (checked with EXPLAIN for emulated and native prepares). QueryData now keeps the binding types recorded by Symfony's DoctrineDataCollector (as the ParameterType case name, since json_encode() rejects the pure enum in the profiler export), and the analyzer maps each positional ? to its type. findBy(), which binds the mapped column type, is not reported. An integration test runs the queries through Symfony's debug middleware and data collector, as the profiler does.MySQLCollationAnalyzer now reports view columns incompatible with the connection collation. The analyzer covered the database default, base tables and foreign key columns, but its table query filters on TABLE_TYPE = 'BASE TABLE', so views were never examined — a schema whose tables are uniformly correct reported clean while a view column silently held a different collation. A CREATE VIEW statement freezes the session collation into the literals of its definition, so a view created from a connection using utf8mb4_general_ci bakes that collation into every IF(...), CONCAT(...) and IFNULL(...) result column it defines. When the application later compares such a column against a literal of its own connection collation, both operands carry the same coercibility, MySQL has no rule to arbitrate between them, and the query fails outright with error 1267 (Illegal mix of collations) rather than degrading. The new check joins information_schema.COLUMNS against information_schema.VIEWS and reports columns whose collation differs from @[@collation_connection](https://github.com/collation_connection), at critical severity, naming each view and column and pointing at the two available fixes: recreating the view from a connection using the application collation, or pinning the literals with an explicit COLLATE, which raises their coercibility and makes the definition independent of the session that created it.CHARACTER_SET_NAME against the charset of @[@collation_connection](https://github.com/collation_connection) and skips anything outside it. Across character sets MySQL converts implicitly and never raises 1267, so the utf8mb3_general_ci columns that DATE_FORMAT() returns inside an otherwise utf8mb4 view are correctly left alone — without that filter they would be reported on every schema that formats a date in a view, which is the common case and never a defect. Verified against a real MariaDB 10.11 schema: the query returns nothing on a healthy database, and returns the offending column on a view deliberately recreated under utf8mb4_general_ci, where the same comparison does raise 1267.mysqldump reissues each view wrapped in the SET collation_connection in force when it was first created, so restoring a dump reintroduces the mismatch regardless of how the target application is configured.OverprivilegedDatabaseUserAnalyzer only ran against mocks or SQLite, and the MySQL/PostgreSQL connection helpers in PlatformAnalyzerTestHelper were unused. tests/Integration/Platform now runs every one of them on both servers, and checks their detections against conditions the tests set up themselves (a MyISAM table, a utf8mb3 table, a table collation, the session sql_mode, statement_timeout and idle_in_transaction_session_timeout). The tests are skipped when no server is reachable; a new Platform Tests CI job provides MySQL 8.4 and PostgreSQL 16 and fails if they are skipped. They were run locally against MariaDB 10.11 and PostgreSQL 16.BulkOperationAnalyzer and DTOHydrationAnalyzer suggestions offer DBAL where it fits. BulkOperationAnalyzer told users to replace repeated UPDATE/DELETE statements with a bulk operation, but its template only showed flush() and clear(), which still sends one statement per entity. The suggestion now leads with one set-based statement matching the detected kind, in DQL or through Connection::executeStatement(), states what it bypasses (callbacks, listeners, cascades, stale loaded entities), and keeps flush and clear in batches as the ORM option. Its title no longer speaks of a memory leak. The DTO hydration suggestion adds plain rows through fetchAllAssociative() for reports that only read values.ImplicitTypeConversionAnalyzer now reports the conversion that actually disables an index. It guessed column types from their names and reported numeric columns compared to quoted numbers (user_id = '42') and date columns compared to integers. EXPLAIN on MariaDB and PostgreSQL shows the index still used in those cases: the literal is converted once. The comparison that loses the index is the reverse, a text column compared to a number (code = 123), which MySQL and MariaDB evaluate by casting every row, while PostgreSQL rejects it. The analyzer now reads column types from the Doctrine metadata and reports only that case; it stays silent without an entity manager or for columns Doctrine does not map. A MySQL platform test runs EXPLAIN on each case against a real server.EntityManagerClearAnalyzer now warns from 50 writes on a table instead of 20. Twenty writes in one request, such as an order saved with twenty lines in a single flush(), hold twenty entities in the identity map and carry no memory risk, yet were reported as a "Memory Leak Risk" warning. The default follows the value config/packages/doctrine_doctor_recommended.yaml already described as realistic for batch processing; analyzers.entity_manager_clear.batch_size_threshold still overrides it.argument.type and argument.templateType identifiers were ignored project-wide, which hid the SqlStructureExtractor crash fixed above. The remaining findings were imprecise annotations: a stale [@phpstan-return](https://github.com/phpstan-return) that overrode the correct [@return](https://github.com/return) in QueryCachingOpportunityAnalyzer, list/array and class-string mismatches, and eight suggestion templates that passed an unchecked context value to basename(). With those fixed, both identifiers are enforced again.ArrayAccess from its mapping objects, but ORM 3 only deprecates it on some of them and not at all on AssociationMapping, so $associationMapping['targetEntity'] works silently today and becomes a fatal error on ORM 4. Thirteen analyzers and helpers now read AssociationMapping and EmbeddedClassMapping through their properties and type(). A custom PHPStan rule, NoMappingArrayAccessRule, reports any array access on a Doctrine mapping object so the pattern cannot come back.MissingTransactionOnBatchAnalyzer, NPlusOneSqlAnalyzer, TypeHintMismatchAnalyzer and MissingTransactionOnBatchIssue are no longer final, like every other analyzer and issue, so they can be extended or decorated the same way.UniqueEntityWithoutDatabaseIndexAnalyzer, StringDefaultExpressionAnalyzer, SingleTableInheritanceNullableColumnAnalyzer and NullablePrimaryKeyAnalyzer read field mappings with array access, deprecated since ORM 3.1 (doctrine/orm#11211); they now go through MappingHelper. OrderByNullableLeadingColumnAnalyzer (Column::getName()), StructuralMissingIndexAnalyzer (Index::getColumns(), Index::isPrimary()) and MissingIndexAnalyzer (Connection::quoteIdentifier()) use the DBAL 4 replacements when available and fall back otherwise. The isPrimary() branch was dropped rather than replaced: the leading column of the primary key is already matched as the leading column of an index. Regression tests assert that none of these deprecations fire.AutoGenerateProxyClassesAnalyzer. It parsed the production YAML for doctrine.orm.auto_generate_proxy_classes, an option DoctrineBundle 3.0 removed. Since this bundle requires DoctrineBundle ^3.0, an application declaring the option no longer boots, so the analyzer could never report anything. DoctrineCacheAnalyzer keeps checking the running EntityManager when generated proxies are in use. The doctrine_doctor.analyzers.auto_generate_proxy_classes option is still accepted so existing configurations keep booting, but it is deprecated and has no effect.PaginationSuggestion, which nothing used.FlushInLoopAnalyzerModern, a draft variant of FlushInLoopAnalyzer that was registered but never tagged, so it never ran. Its real-database scenarios now exercise FlushInLoopAnalyzer.Pagination checks no longer mistake SQL text for ordering. ORDER BY inside a literal, comment or unrelated subquery does not suppress the warning. The Doctrine paginator exception now checks the actual ordering of its dctrn_result derived table, and the single-row exception reads the outer query's limit.
PostgreSQL index candidates must match a bare-column comparison. A column name appearing in a string value or inside a function or cast no longer makes its ordinary index suppress a missing-index warning. Partial indexes and filters containing alternative OR branches are not inferred to be usable from the column name alone.
The second-level cache opportunity stayed next to the N+1 it duplicates. With bound parameters recorded, as the Symfony profiler does, a lazy-loading loop also raised Doctrine 2LC Opportunity (10 fast entity loads), which the deduplicator did not merge into the N+1: its title pattern missed "10 fast entity loads", its entity pattern read loads from "entity loads" because /i made [A-Z] match lowercase, and a description mentioning "benefit from Doctrine" was read as table Doctrine before the SQL. The title pattern now accepts up to two words before the unit and loads, the entity pattern only matches capitalised names, and the SQL FROM table is read before description prose. The integration test query logger now records bound parameters and types like Symfony's profiler, so integration tests see what production sees; that is how this surfaced.
DQLInjectionAnalyzer reported one DQL literal twice. A query such as WHERE u.email = '[email protected]' matched both the unparameterized-literal check and the generic pattern scan, which produced DQL Injection Risk: ... concatenated literal value and Security Warning: 1 queries with potential injection risks for the same statement. Each query now yields one issue: the critical pattern issue when the SQL shows a tautology or similar, otherwise the unparameterized-literal one. Integration tests cover both through real DQL.
NPlusOneAnalyzer reported the same query repeated as an N+1. Five identical SELECT COUNT(...) calls were reported as N+1 Query Detected (unknown) with fetch-join advice, though nothing varies between runs and no join can help. The repeated query analyzers already cover that case with caching advice. A group whose statements are identical, without placeholders or with the same bound parameters, is now left to them; a group with placeholders but no recorded parameters is still reported.
TransactionBoundaryAnalyzer reported the recommended wrapInTransaction() pattern as a critical nested transaction. DBAL 4 nests transactions with savepoints, so a flush() inside wrapInTransaction() runs SAVEPOINT DOCTRINE_2, which MySQL, MariaDB and PostgreSQL honour. The analyzer counted the savepoint as a nested transaction and claimed inner transactions are ignored. Savepoints now only track nesting; the issue is raised for a second START TRANSACTION on an open transaction, and its description now says what actually happens: MySQL and MariaDB commit the open transaction implicitly. Savepoints also broke the other checks: each flush in an explicit transaction opened its own savepoint, so Multiple Flush in Transaction counted one flush per savepoint and never fired on DBAL 4, and each savepoint reset the transaction start time used by the long-transaction check. Savepoints now share the flush count and start time of their enclosing transaction. Two existing integration tests, skipped because "SQLite emits SAVEPOINT" (DBAL 4 does on every platform), run again, and a new one covers wrapInTransaction() with a real flush.
PaginationWithoutOrderByAnalyzer asked for an ORDER BY that Doctrine's Paginator had already applied. The Paginator moves the DQL ORDER BY into a derived table and puts the LIMIT on the outer query, so a correctly ordered paginated query was reported as non-deterministic, with advice to add the clause it already had. An ORDER BY in a subquery now counts; a Paginator query without any ORDER BY is still reported. An integration test runs both through the real Paginator.
One N+1 loop was reported up to three times. IssueDeduplicator merges the issues several analyzers raise for one repeated query by table and count, but it read the table from issue text with patterns lacking word boundaries: "execution time" yielded the table time, "Collection N+1" the table n, "Pagination Without" the table without. On Doctrine's SQL, a lazy to-one loop therefore kept N+1 Query Detected, Lazy Loading in Loop and Lazy Loading Detected, and a lazy collection loop kept N+1 Query Detected and Frequent Query Executed. Each loop now yields the single N+1 issue, with the others attached as duplicates. An integration test runs the real analyzers on both loops.
NPlusOneSqlAnalyzer reported ORM lazy loading as an N+1 loop "in DBAL code". It grouped every repeated SELECT, so the classic lazy-loading loop produced two issues: NPlusOneAnalyzer's, and this one, which blamed DBAL code and suggested hand-written SQL where a fetch join is the fix. Both survived deduplication. Queries Doctrine generated, recognised by its <column>_<n> column aliases or a Doctrine\ORM backtrace frame, are now left to NPlusOneAnalyzer. For real DBAL loops the suggestion now shows the repeated query rewritten with IN (?) and an ArrayParameterType parameter.
SQL injection suggestions showed code from the wrong layer or an API DBAL 4 rejects. The template used by QueryBuilderBestPracticesAnalyzer, which detects concatenation in an ORM QueryBuilder, showed raw DBAL code and fixed it with bindValue(1, $id, \PDO::PARAM_INT), which DBAL 4 rejects because it expects a ParameterType. The fix now matches the layer: setParameter() for a QueryBuilder, and executeQuery() with ParameterType for raw SQL. The raw-query suggestion also no longer calls an (int) cast unsafe.
MissingIndexAnalyzer reported missing indexes on PostgreSQL for plans that used one. Its PostgreSQL plan reader never filled key or possible_keys, so an Index Scan or bitmap scan estimating at least 1000 rows, or a Seq Scan PostgreSQL chose over an existing index for low selectivity, was reported with a CREATE INDEX suggestion. Plan nodes now carry the index they use (a bitmap heap scan takes it from its bitmap index scan child), and a sequential scan whose Filter starts an existing index lists that index as a candidate, like MySQL's possible_keys. A PostgreSQL platform test runs both cases, plus a filter on an unindexed column that is still reported, against a real server.
MissingIndexAnalyzer suggested creating indexes that already existed. It reported "Missing Index Detected" with a CREATE INDEX suggestion whenever EXPLAIN estimated at least 1000 rows, even when the plan used an index (ref, range, full index scan) or listed one in possible_keys that the optimizer skipped for low selectivity. On MariaDB, a year-wide range on an indexed created_at column was reported this way. A missing index is now reported only when EXPLAIN shows no candidate index at all; a MySQL platform test runs both cases against a real server.
NestedRelationshipN1Analyzer missed the nested N+1 a loop actually produces. It required each level's queries to form a contiguous block, but a foreach calling $comment->getPost()->getAuthor() makes Doctrine load the levels in turn: post, author, next post, next author. On real Doctrine SQL the analyzer therefore never reported the classic case. A non-contiguous group now counts when it loads rows by primary key (lazy to-one proxies) and only alternates with other such groups, with no unrelated SELECT in between; alternating loads by foreign key, which are per-row collection loads, stay excluded. A new integration test runs the loop on a real EntityManager and failed before this fix.
FindAllAnalyzer reported filtered queries as unrestricted. It inferred the presence of a WHERE clause from the bare columns found in it, so a query filtering only on a function of a column, such as the case-insensitive lookup WHERE LOWER(email) = ?, was reported as selecting a whole table. It now asks the SQL parser whether the statement has a WHERE clause at all.
SqlStructureExtractor crashed with a custom join extractor. When given a JoinExtractorInterface other than SqlJoinExtractor, it built its default pattern detector with null, which the detector's constructor rejects with a TypeError. It now falls back to a default SqlJoinExtractor for the detector. Found by re-enabling PHPStan's argument.type check.
Required to-one associations were never detected. EntityStateConsistencyAnalyzer (required association without cascade persist) and PropertyTypeMismatchAnalyzer (nullable property on a NOT NULL join column) read the join column as an array, but on ORM 3 it is a JoinColumnMapping object, so every association was treated as nullable and neither check could ever fire. They now use MappingHelper::isJoinColumnNullable(), and fixtures cover both detections.
CascadeAllAnalyzer under-rated cascade="all" on ManyToMany and OneToOne associations. It guessed the association type from the mapping class name, looking for ManyToManyAssociation and OneToOneAssociation, but ORM 3 instantiates ManyToManyOwningSideMapping, OneToOneOwningSideMapping and their inverse-side counterparts. Such associations were reported as Unknown, so a ManyToMany cascading everything to an independent entity came out as a warning instead of critical. The test covering it accepted either severity, which hid the problem; it now asserts critical. The same class-name guessing was duplicated in eight analyzers and helpers; they now share MappingHelper::getAssociationType(), which reads AssociationMapping::type().
FlushInLoopAnalyzer missed flush-in-loop in real applications. It only recognised a flush when a write was immediately followed by a SELECT. But flush() wraps its writes in a transaction, and Symfony's Doctrine debug middleware logs "START TRANSACTION" and "COMMIT" as queries, so in a profiled request the write is followed by COMMIT and the pattern never matched; the canonical persist() + flush() loop, with no read in between, was not recognised either. A COMMIT closing at least one write now ends a flush. The only test running the analyzer against a real database targeted the unused draft variant; it now covers the active analyzer, and failed before this fix.
Cascade findings were reported twice. The "unified" CascadeAnalyzer re-implemented the rules of CascadeAllAnalyzer, CascadeRemoveOnIndependentEntityAnalyzer and CascadePersistOnIndependentEntityAnalyzer with the same titles, and the Integrity/* glob tagged it alongside them, so every cascade="all", cascade="remove" or cascade="persist" finding ran twice. It covered no case the dedicated analyzers miss and had no tests, so it is removed.
The deduplicator hid findings on other fields of the same entity. Metadata analyzers reuse one title for every field they flag, and the fallback signature was built from the title and the entity only, so two cascade="all" associations on the same entity collapsed into one issue and the second field disappeared from the profiler. The signature now includes the issue's field when it carries one.
DoctrineCacheAnalyzer reported proxy auto-generation that Doctrine ignores. With native lazy objects (ORM 3.4+ on PHP 8.4, always on since DoctrineBundle 3.1) Doctrine no longer generates proxy classes, so auto_generate_proxy_classes has no effect, and Configuration::getAutoGenerateProxyClasses() is deprecated (doctrine/orm#12005). The runtime check now skips when native lazy objects are enabled, instead of raising a critical finding and a deprecation on every profiled request.
The partial object advice could turn a full load into an N+1. PartialObjectAnalyzer recommended SELECT PARTIAL first and described the result as read-only. With native lazy objects, reading a field left out of the PARTIAL list loads it on access, one query per entity, and partial objects stay managed rather than read-only. The suggestion now leads with DTO (SELECT NEW), array and scalar hydration, keeps partial objects last, and states the lazy-loading cost; the excessive_hydration template carries the same warning.
Pagination advice pointed to a deprecated class. ORM 3.7 deprecates Doctrine\ORM\Tools\Pagination\Paginator in favour of OffsetPaginator, and adds a CursorPaginator for keyset pagination. When they are installed, SetMaxResultsWithCollectionJoinAnalyzer now recommends OffsetPaginator with a Window, and DeepOffsetPaginationAnalyzer points to CursorPaginator; older ORM versions keep the previous advice.
Queries issued by the ORM 3.7 paginators were not recognised as paginator queries. PaginatorQueryDetector only matched Pagination\Paginator, so JoinOptimizationAnalyzer and UnusedEagerLoadAnalyzer would report the internal queries of OffsetPaginator and CursorPaginator as if the application had written them. Any class of the Doctrine\ORM\Tools\Pagination namespace now counts.
SuggestionMetadata carries the Severity, but render() only ever receives the context array, so every template hardcoded its own alert-danger / alert-warning / alert-info class. FlushInLoopAnalyzer shows the cost most clearly: it picks critical, warning or info from the flush count, while the template always drew the critical red banner, so three flushes in a loop looked exactly as severe as sixty. SuggestionFactory now seeds the context with the metadata severity, leaving an explicit value untouched, and a severityAlertClass() helper maps it to the alert class. Applied to the twelve templates whose banner contradicted the severity they were rendered with.Performance/index raised a TypeError as soon as any key was absent, and others rendered placeholder text such as Rename table from '' to ''. Every context read now has an explicit default and a cast, and Integrity/code_suggestion no longer returns an empty description.$e(), but ucfirst($fieldName), strtolower() and substr() were echoed raw in 20 templates — 88 occurrences. These values come from Doctrine metadata rather than request input, so this was not exploitable, but the escaping was structurally absent rather than deliberately skipped.<ul><li> markup by a markdown conversion, 46 templates carried emoji in the profiler output, and 54 opened documentation links with target="_blank" and no rel.src/Template/helpers.php now own it, and the naming_convention cluster is rewritten on top of them as the worked example, from 165 lines to 53. Visible text is escaped with ENT_NOQUOTES so apostrophes stay readable in the panel, while attributes keep full quote escaping. Rendered output of all 113 templates was snapshotted before the change and compared after: no drift in rendered text or descriptions, and no CSS class or HTML tag lost.src/Template/Suggestions/ is no longer excluded from ECS and PHPStan. The exclusion was documented as covering "template files with PHP interpolation", but the templates pass both, level 8 included. Two test classes and five validator checks now guard the directory: every template renders with an empty context without raising, the banner follows the severity at all three levels, the layout helpers escape markup, and the validator rejects emoji, missing rel attributes, context destructuring without defaults, and echoing the result of a function that is not a known escaper.EXAMPLE_SECURE_TEMPLATE shipped as documentation for the SafeContext auto-escaping API but was never referenced by any analyzer, factory or test, while still being picked up as a renderable template. docs/advanced/template-security.md covers the same ground, including the same example.The setMaxResults() with Collection Join alert now carries the facts of the query that triggered it instead of a fixed block of theory: the LIMIT value and the number of fetch-joined tables, the row count when the profiler reports one, and an explicit note when a result set reached its LIMIT and was therefore very likely truncated in the middle of a root entity.
When the query is offset past the first page, the alert also warns about the severe failure mode the old message never mentioned: a batch loop bounded by a root entity count exits early and never reads the remaining entities, losing whole entities rather than just collection items. Both remediations are given — paginating on identifiers, and Paginator — with the trade-off between them.
Every enrichment degrades to the base message when no row count is available. Detection logic is unchanged.
hasOffset() reported an offset for every LIMIT query. The SQL parser exposes an offset of 0 for a bare LIMIT, and the guard tested it with '' !== (string) $offset, which holds for "0". PaginationWithoutOrderByAnalyzer derives its severity from this call, so a plain LIMIT without ORDER BY was reported as LIMIT/OFFSET pagination at warning instead of LIMIT at info. The method had no test coverage; a case per pagination form was added, including MySQL comma syntax.composer.lock is not tracked, so every run resolves afresh, and CVE-2026-53965 covers mcp/sdk >=0.5.0,<0.7.1. symfony/ai-symfony-mate-extension was pinned to ^0.10, whose ai-mate v0.10 requires mcp/sdk ^0.6 exclusively. The constraint now allows ^0.11|^0.12, reaching the patched v0.7.1.ShortVariable now applies to closure parameters), and an unreachable duplicate switch case reported by newer PHPStan.Full Changelog: https://github.com/ahmed-bhs/doctrine-doctor/compare/v2.10.0...v2.10.1
Export JSON button on the Doctrine Doctor panel heading downloads the current report (issues, stats, grouped query timings) as a .json file. Rendered into the panel and saved client-side via a Blob: no route, no routing configuration needed in the host application. Contributed by @makomweb in #120.JSON_HEX_TAG so captured SQL containing </script> or <!--<script cannot break out of the panel's script block.Full changelog: https://github.com/ahmed-bhs/doctrine-doctor/compare/v2.9.5...v2.10.0
OverprivilegedDatabaseUserAnalyzer: detects privileged, empty, or passwordless database users and suggests switching to a least-privilege account.HardcodedDatabaseCredentialsAnalyzer: detects database credentials embedded directly in DBAL configuration and suggests moving them to environment variables.NPlusOneAnalyzer: identifies repeated findBy()/findOneBy()-style lookups on non-key columns and suggests batching with IN queries or request-level caching.SensitiveDataExposureAnalyzer: now also flags public getters that expose sensitive entity fields without explicit protection.PropertyTypeMismatchAnalyzer: now attaches concrete fix suggestions for PHP/Doctrine type mismatches, including nullability mismatches.CollectionInitializationAnalyzer suggestion template now uses the actual mappedBy value when available.CollectionInitializationAnalyzer: now supports PHP constructor promotion when detecting collection initialization, fixing the false positive reported in #67.CollectionEmptyAccessAnalyzer in favor of the AST-based collection initialization analysis path.SQLInjectionInRawQueriesAnalyzer: now detects unparameterized literals in WHERE clauses of raw SQL queries as an injection risk, instead of only flagging active attack patterns.DQLInjectionAnalyzer: now detects Doctrine-generated SQL with concatenated literals and empty bound parameters, indicating unsafe DQL string concatenation.OnDeleteCascadeMismatchAnalyzer now assigns CRITICAL severity for orm_cascade_db_setnull and orm_orphan_db_setnull mismatcheson_delete_cascade_mismatch now render context-aware code snippets per mismatch typeOneToOneInverseSideAnalyzer: detects bidirectional OneToOne mappedBy sides that silently force Doctrine to execute N+1 queries on every load, even when the relation is never accessed. Suggests flipping the owning side, going unidirectional, or using a fetch joinone_to_one_inverse_side analyzerCompositeKeyComplexityAnalyzer: use ShortClassNameTrait, proper return types, and MappingHelper for Doctrine 2/3/4 compatibilityCompositeKeyComplexityAnalyzer: detects entities using composite primary keys that limit Doctrine ORM features (no getReference(), slower identity map, complex FK mappings). Severity: WARNING for 2 columns, CRITICAL for 3+ or when referenced by other entitiescomposite_key_complexity analyzerOnDeleteCascadeMismatchAnalyzer now assigns CRITICAL severity for orm_cascade_db_setnull and orm_orphan_db_setnull mismatches (previously WARNING)on_delete_cascade_mismatch now render context-aware code snippets per mismatch type instead of a generic templateJoinColumnNonPrimaryKeyAnalyzer: detects associations where referencedColumnName points to a non-primary-key columnDuplicatePrivateFieldInHierarchyAnalyzer: detects private fields with the same name in an entity and its mapped parent classesSuggested Fix, Hide suggestion).issue-body background color to #fffefc for better visual consistency.isset.initializedProperty error: use ReflectionProperty::isInitialized() for readonly property check after unserialization.rel="noopener noreferrer" on external Doctrine documentation link.isset.initializedProperty error: use ReflectionProperty::isInitialized() for readonly property check after unserialization.rel="noopener noreferrer" on external Doctrine documentation link (target="_blank")..alert-warning, .alert-danger, and .dd-suggestion-meta-intro blocks (less aggressive text contrast).IssueReconstructor (#32)<pre><code> to prevent entity encoding (direct commit 0d8d54e)webmozart/assert constraint to support v2.x (direct commit b4fe7b6)bitbag/coding-standard dependency (direct commit e7b59ae)0c70d90)webmozart/assert constraint to support v2.x (direct commit b4fe7b6)bitbag/coding-standard dependency (direct commit e7b59ae)IssueReconstructor type validation,PropertyTypeMismatchAnalyzer fixtures and fix SafeContext test.c71ebaf, f12e90f, b19d58e).23ff95a.First stable release of the 2.x series. Requires PHP 8.4+, Doctrine Bundle 3 and Doctrine ORM 3/4.
IssueReconstructor was instantiated without the template renderer after Symfony profiler deserializationSafeContext::offsetGet() now returns null for missing keys instead of throwing, so templates with optional context variables (like trigger_location) no longer crasheager_loading template missing trigger_location keyleft_join_with_not_null template wrong context keydoctrine/doctrine-bundle ^3.0 (drop ^2.x)doctrine/orm ^3.0|^4.0 (drop ^2.x)webmozart/assert ^1.12#[\Override], typed constants, array_find())Upgrading from 1.x? Update your composer.json to "ahmed-bhs/doctrine-doctor": "^2.0" and ensure you run Doctrine Bundle 3 with Doctrine ORM 3+.
Full Changelog: https://github.com/ahmed-bhs/doctrine-doctor/compare/v1.0.0...v1.0.1
PgiRedmineProject::$projects instead of generic redmineProject)PgiProjectCrudController::{closure}() in PgiProjectCrudController.php:267)collection_eager_loading.php with context-aware solutions (addSelect vs EXTRA_LAZY)decimal/bigint type mapping (returns string, not float)Full Changelog: https://github.com/ahmed-bhs/doctrine-doctor/compare/v1.0.1...v1.0.2
Runtime analysis tool for Doctrine ORM integrated into Symfony Web Profiler.
composer require --dev ahmed-bhs/doctrine-doctor
Auto-configured via Symfony Flex.
| Category | Count | Examples |
|---|---|---|
| Performance | 25 | N+1 queries, missing indexes, slow queries, hydration |
| Security | 4 | DQL/SQL injection, sensitive data exposure |
| Code Quality | 29 | Cascade config, type mismatches, architectural violations |
| Configuration | 8 | Charset, timezone, Gedmo traits, database setup |
https://github.com/ahmed-bhs/doctrine-doctor/compare/v1.1.0-alpha.3...v1.0.0
How can I help you explore Laravel packages today?