Skip to content

Conversation

@binaryfire
Copy link
Contributor

@binaryfire binaryfire commented Dec 19, 2025

This PR fixes config merging in ConfigFactory and ProviderConfig by implementing custom merge logic that correctly handles all Hyperf config patterns.

Background

Originally, both classes used array_merge_recursive. PR #290 changed ConfigFactory to use array_replace_recursive, which fixed scalar values but broke list arrays. And using Arr::merge doesn't work for mixed arrays.

The problem with all standard merge options:

Function Pure lists Scalars Mixed arrays (listeners with priority)
array_merge_recursive ✅ Combines ❌ Converts to arrays ✅ Preserves
array_replace_recursive ❌ Replaces at indices ✅ Later value wins ✅ Preserves
Arr::merge ✅ Combines (dedup) ✅ Later value wins ❌ Loses string keys

Bug 1: array_merge_recursive breaks scalars

  $configA = ['database' => ['driver' => 'sqlite']];
  $configB = ['database' => ['driver' => 'sqlite', 'foreign_key_constraints' => true]];

  // Result: ['driver' => ['sqlite', 'sqlite']]  ❌

Bug 2: array_replace_recursive breaks lists

  $providerConfig = ['commands' => ['CommandA', 'CommandB', 'CommandC']];
  $appConfig = ['commands' => ['CustomCommand']];

  // Result: ['commands' => ['CustomCommand', 'CommandB', 'CommandC']]  ❌

Bug 3: Arr::merge loses string keys in mixed arrays

Hyperf uses this pattern for listeners with priorities:

  'listeners' => [
      ModelEventListener::class,
      ModelHookEventListener::class => 99,  // string key with priority
  ]

Arr::merge treats this as a list and loses the string key:

  // Result: ['ListenerA', 'ModelEventListener', 99]  ❌
  // The class name key is lost, only the priority value remains

This caused ModelHookEventListener to not be registered, breaking model event hooks like HasUuids::creating().

The fix

Custom merge logic that handles each key type correctly:

  • Numeric keys → append (with deduplication)
  • String keys → override (recurse if both are arrays)
  private static function mergeTwo(array $base, array $override): array
  {
      $result = $base;

      foreach ($override as $key => $value) {
          if (is_int($key)) {
              // Numeric key - append if not already present
              if (! in_array($value, $result, true)) {
                  $result[] = $value;
              }
          } elseif (! array_key_exists($key, $result)) {
              $result[$key] = $value;
          } elseif (is_array($value) && is_array($result[$key])) {
              $result[$key] = self::mergeTwo($result[$key], $value);
          } else {
              $result[$key] = $value;
          }
      }

      return $result;
  }

Changes

  • ProviderConfig — Custom mergeTwo() replacing Arr::merge
  • ConfigFactory — Custom mergeTwo() replacing Arr::merge

Tests

  • ProviderConfigTest — 16 tests covering all merge edge cases including the new listeners-with-priority pattern
  • ConfigFactoryTest — 3 tests verifying ConfigFactory uses the same merge semantics
  • MergeIntegrationTest - comprehensive test using actual config files to make sure merging works as expected

Replace array_replace_recursive with Arr::merge in ConfigFactory, and
override ProviderConfig::merge() to use Arr::merge instead of the parent's
array_merge_recursive.

Arr::merge correctly:
- Combines list arrays (commands, listeners) by appending with deduplication
- Replaces scalar values in associative arrays (later value wins)
- Recursively merges nested associative arrays

This fixes two bugs:
1. array_merge_recursive (Hyperf's default) converts duplicate scalar keys
   into arrays: ['driver' => 'pgsql'] + ['driver' => 'pgsql'] became
   ['driver' => ['pgsql', 'pgsql']]
2. array_replace_recursive (previous fix) replaced list items at matching
   indices instead of combining them: commands from providers were being
   overwritten instead of accumulated

Includes comprehensive test coverage for both ProviderConfig and ConfigFactory
merge behavior.
@binaryfire binaryfire changed the title fix: Use Arr::merge in ConfigFactory and ProviderConfig for correct config merging fix: use Arr::merge in ConfigFactory and ProviderConfig for correct config merging Dec 19, 2025
@binaryfire binaryfire marked this pull request as draft December 20, 2025 03:45
@binaryfire binaryfire changed the title fix: use Arr::merge in ConfigFactory and ProviderConfig for correct config merging fix: ConfigFactory and ProviderConfig config merging Dec 20, 2025
@binaryfire binaryfire marked this pull request as ready for review December 20, 2025 04:48
@binaryfire
Copy link
Contributor Author

@albertcht I've also added MergeIntegrationTest, which uses actual config files to make sure merging works as expected for edge cases.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes config merging bugs in ConfigFactory and ProviderConfig by implementing custom merge logic that correctly handles all Hyperf config patterns. The previous implementation had issues with scalar value duplication, list array replacement, and loss of string keys in mixed arrays.

Key Changes:

  • Implemented custom mergeTwo() method in both ProviderConfig and ConfigFactory that handles numeric keys (append with deduplication) and string keys (recursive merge with override) correctly
  • Fixed the critical bug where priority listeners with string keys were being lost during merge
  • Added comprehensive test coverage with 16 unit tests in ProviderConfigTest, 3 tests in ConfigFactoryTest, and 5 integration tests in MergeIntegrationTest

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/config/src/ProviderConfig.php Added custom merge() and mergeTwo() methods to replace Arr::merge, includes special handling for PriorityDefinition
src/config/src/ConfigFactory.php Replaced array_replace_recursive with custom mergeTwo() method for correct list and scalar merging
tests/Config/ProviderConfigTest.php Comprehensive unit tests (16 tests) covering all merge edge cases including priority listeners, scalar preservation, and deduplication
tests/Config/ConfigFactoryTest.php Tests verifying ConfigFactory uses the same merge semantics as ProviderConfig (3 tests)
tests/Config/MergeIntegrationTest.php Integration tests using real config fixtures to verify end-to-end merging behavior (5 tests)
tests/Config/fixtures/base/*.php Base configuration fixtures representing initial config state
tests/Config/fixtures/override/*.php Override configuration fixtures representing configs that merge with base
tests/Config/fixtures/expected/*.php Expected results after merging base and override configs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +113 to +128
if (isset($result['dependencies'])) {
$result['dependencies'] = [];
foreach ($arrays as $item) {
foreach ($item['dependencies'] ?? [] as $key => $value) {
$depend = $result['dependencies'][$key] ?? null;
if (! $depend instanceof PriorityDefinition) {
$result['dependencies'][$key] = $value;
continue;
}

if ($value instanceof PriorityDefinition) {
$depend->merge($value);
}
}
}
}
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The special handling for dependencies with PriorityDefinition has a logic issue. When iterating through all arrays and processing dependencies, the code resets $result['dependencies'] to an empty array at line 114, which discards any dependencies that were already merged by the mergeTwo function. Then it iterates through all arrays again, but it checks if $depend is an instance of PriorityDefinition when $depend comes from the now-empty $result['dependencies'], which means it will never be a PriorityDefinition on the first iteration.

The logic should either:

  1. Process dependencies after the initial merge is complete (not reset the array), or
  2. Build dependencies separately and then add them to the result

Consider refactoring this to preserve the merged dependencies and only apply special PriorityDefinition merging logic when needed.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@albertcht This is a false positive. The "reset to empty and re-iterate" pattern is intentional and is an exact copy from https://github.com/hyperf/hyperf/blob/master/src/config/src/ProviderConfig.php.

Why it works this way:

The initial merge (mergeTwo in this PR, array_merge_recursive in Hyperf's original) doesn't understand PriorityDefinition objects—it would just keep the last value for each key. The post-merge step rebuilds dependencies from scratch with special handling:

  1. First time a key is seen: $depend = null, so ! null instanceof PriorityDefinition → true → store the value
  2. Subsequent encounters: If the stored value is not a PriorityDefinition, override it (last one wins). If it is a PriorityDefinition and the new value is also one, call $depend->merge($value) to combine priorities.

This correctly handles the DI priority system where multiple providers can register competing implementations for the same interface, with the highest priority winning at resolution time.

Add 5 tests covering PriorityDefinition handling in ProviderConfig::merge():
- testMergeDependenciesWithPriorityDefinition
- testMergeThreeConfigsWithPriorityDefinition
- testMergePlainDependencyThenPriorityDefinition
- testMergePriorityDefinitionThenPlainDependency
- testMergeMixedDependencies

These tests verify the special dependency merge logic that handles
Hyperf's PriorityDefinition for DI priority resolution.
- Make ProviderConfig::mergeTwo() public static so ConfigFactory can use it
- Remove duplicate mergeTwo() implementation from ConfigFactory (32 lines)
- Add test verifying mergeTwo() works as public API
- Update docblocks to reflect the shared architecture

Both classes now use identical merge semantics from a single source of truth.
@albertcht albertcht added the bug Something isn't working label Dec 21, 2025
@albertcht albertcht merged commit 2dabd18 into hypervel:main Dec 21, 2025
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants