├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer-require-checker.json ├── composer.json ├── psalm83-84.xml ├── rector.php ├── roave-bc-check.yaml ├── src ├── Command │ ├── ConfigCommandProvider.php │ ├── CopyCommand.php │ ├── InfoCommand.php │ └── RebuildCommand.php ├── Composer │ ├── ConfigSettings.php │ ├── EventHandler.php │ ├── MergePlanProcess.php │ ├── Options.php │ ├── PackageFile.php │ ├── PackageFilesProcess.php │ ├── PackagesListBuilder.php │ └── ProcessHelper.php ├── Config.php ├── ConfigInterface.php ├── ConfigPaths.php ├── Context.php ├── DataModifiers.php ├── FilesExtractor.php ├── MergePlan.php ├── Merger.php └── Modifier │ ├── RecursiveMerge.php │ ├── RemoveFromVendor.php │ ├── RemoveGroupsFromVendor.php │ ├── RemoveKeysFromVendor.php │ └── ReverseMerge.php └── tools ├── .gitignore └── composer-require-checker └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Config Change Log 2 | 3 | ## 1.6.1 under development 4 | 5 | - no changes in this release. 6 | 7 | ## 1.6.0 February 05, 2025 8 | 9 | - New #173: Allow to use option "config-plugin-file" in packages (@vjik) 10 | - New #175: Add `yii-config-info` composer command (@vjik) 11 | - Chg #175: Raise minimum Composer version to 2.3 (@vjik) 12 | - Chg #187: Change PHP constraint in `composer.json` to `~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0` (@vjik) 13 | - Enh #172, #173: Refactoring: extract config settings reader to separate class (@vjik) 14 | - Enh #175: Minor refactoring of internal classes `Options` and `ProcessHelper` (@vjik) 15 | - Enh #186: Raise the minimum PHP version to 8.1 and minor refactoring (@vjik) 16 | - Bug #186: Explicitly mark nullable parameters (@vjik) 17 | 18 | ## 1.5.0 December 25, 2023 19 | 20 | - New #155: Add ability to specify recursion depth for recursive modifier (@vjik) 21 | - Enh #157: Remove unnecessary code in `PackagesListBuilder` (@vjik) 22 | - Bug #153: Do not throw "Duplicate key…" exception when using nested groups (@vjik) 23 | - Bug #163: References to another configs use reverse and recursive modifiers of root group now (@vjik) 24 | 25 | ## 1.4.0 November 17, 2023 26 | 27 | - Enh #152: Add plugin option "package-types" that define package types for process, by default "library" and 28 | "composer-plugin" (@vjik) 29 | 30 | ## 1.3.1 November 17, 2023 31 | 32 | - Bug #145: Use composer library and plugins only, instead of any packages before (@vjik) 33 | - Bug #150: Empty configuration groups from packages were not added to merge plan (@vjik) 34 | 35 | ## 1.3.0 February 11, 2023 36 | 37 | - Enh #131: Add ability to use `Config` without params (@vjik) 38 | 39 | ## 1.2.0 February 08, 2023 40 | 41 | - Enh #119: Improve performance of collecting data for `ReverseMerge` and `RecursiveMerge` (@samdark) 42 | - Enh #122: Raise minimal PHP version to 8.0 (@vjik, @xepozz) 43 | - Enh #130: Add ability to change merge plan file path (@vjik) 44 | 45 | ## 1.1.1 January 05, 2022 46 | 47 | - Enh #110: Improve the error message by displaying a name of the group where the error occurred when merging (@devanych) 48 | 49 | ## 1.1.0 December 31, 2021 50 | 51 | - New #108: Add `Yiisoft\Config\ConfigInterface` to allow custom implementations of a config loader (@devanych) 52 | 53 | ## 1.0.0 December 17, 2021 54 | 55 | - Initial release. 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software () 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | Config 5 | 6 |

Yii Config

7 |
8 |

9 | 10 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/config/v/stable)](https://packagist.org/packages/yiisoft/config) 11 | [![Total Downloads](https://poser.pugx.org/yiisoft/config/downloads)](https://packagist.org/packages/yiisoft/config) 12 | [![Build status](https://github.com/yiisoft/config/workflows/build/badge.svg)](https://github.com/yiisoft/config/actions) 13 | [![Code Coverage](https://codecov.io/gh/yiisoft/config/graph/badge.svg?token=V8gfhkSUoP)](https://codecov.io/gh/yiisoft/config) 14 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fconfig%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/config/master) 15 | [![static analysis](https://github.com/yiisoft/config/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/config/actions?query=workflow%3A%22static+analysis%22) 16 | [![type-coverage](https://shepherd.dev/github/yiisoft/config/coverage.svg)](https://shepherd.dev/github/yiisoft/config) 17 | 18 | This [Composer](https://getcomposer.org) plugin provides assembling of configurations distributed with composer 19 | packages. It is implementing a plugin system which allows to provide the configuration needed to use a package directly when installing it to make it run out-of-the-box. 20 | The package becomes a plugin holding both the code and its default configuration. 21 | 22 | ## Requirements 23 | 24 | - PHP 8.1 or higher. 25 | - Composer 2.3 or higher. 26 | 27 | ## Installation 28 | 29 | ```shell 30 | composer require yiisoft/config 31 | ``` 32 | 33 | ## How it works? 34 | 35 | The package consist of two parts: Composer plugin and config loader. 36 | 37 | After composer updates its autoload file, and that happens after `dump-autoload`, `require`, `update` or `remove`, 38 | Composer plugin: 39 | 40 | - Scans installed packages for `config-plugin` extra option in their `composer.json`. 41 | - Writes a merge plan into `config/.merge-plan.php`. It includes configuration from each package `composer.json`. 42 | 43 | In the application entry point, usually `index.php`, we create an instance of config loader and require a configuration 44 | we need: 45 | 46 | ```php 47 | use Yiisoft\Config\Config; 48 | use Yiisoft\Config\ConfigPaths; 49 | 50 | $config = new Config( 51 | new ConfigPaths(dirname(__DIR__)), 52 | ); 53 | 54 | $web = $config->get('web'); 55 | ``` 56 | 57 | The `web` in the above is a config group. The config loader obtains it runtime according to the merge plan. 58 | The configuration consists of three layers that are loaded as follows: 59 | 60 | - Vendor configurations from each `vendor/package-name`. These provide default values. 61 | - Root package configurations from `config`. These may override vendor configurations. 62 | - Environment specific configurations from `config`. These may override root and vendor configurations. 63 | 64 | > Please note that same named keys are not allowed within a configuration layer. 65 | 66 | When calling the `get()` method, if the configuration group does not exist, an `\ErrorException` will be thrown. 67 | If you are not sure that the configuration group exists, then use the `has()` method: 68 | 69 | ```php 70 | use Yiisoft\Config\Config; 71 | use Yiisoft\Config\ConfigPaths; 72 | 73 | $config = new Config( 74 | new ConfigPaths(dirname(__DIR__)), 75 | ); 76 | 77 | if ($config->has('web')) { 78 | $web = $config->get('web'); 79 | } 80 | ``` 81 | 82 | ## Config groups 83 | 84 | Each config group represents a set of configs that is merged into a single array. It is defined per package in 85 | each package `composer.json`: 86 | 87 | ```json 88 | "extra": { 89 | "config-plugin": { 90 | "params": [ 91 | "params.php", 92 | "?params-local.php" 93 | ], 94 | "common": "common.php", 95 | "web": [ 96 | "$common", 97 | "web.php", 98 | "../src/Modules/*/config/web.php" 99 | ], 100 | "other": "other.php" 101 | } 102 | } 103 | ``` 104 | 105 | In the above example the mapping keys are config group names and the values are configuration files and references to other config groups. 106 | The file paths are relative to the [source-directory](#source-directory), which by default is the path where `composer.json` is located. 107 | 108 | ### Markers 109 | 110 | - `?` - marks optional files. Absence of files not marked with this marker will cause exception. 111 | 112 | ```php 113 | "params": [ 114 | "params.php", 115 | "?params-local.php" 116 | ] 117 | ``` 118 | 119 | It's okay if `params-local.php` will not be found, but it's not okay if `params.php` will be absent. 120 | 121 | - `*` - marks wildcard path. It means zero or more matches by wildcard mask. 122 | 123 | ```php 124 | "web": [ 125 | "../src/Modules/*/config/web.php" 126 | ] 127 | ``` 128 | 129 | It will collect all `web.php` in any sub-folders of `src/Modules/` in `config` folder. 130 | However, if the configuration folder is packaged as part of the `PHAR` archive, the configuration 131 | files will not be uploaded. In this case, you must explicitly specify each configuration file. 132 | 133 | - `$` - reference to another config by its group name. 134 | 135 | ```php 136 | "params": [ 137 | "params.php", 138 | "?params-local.php" 139 | ], 140 | "params-console": [ 141 | "$params", 142 | "params-console.php" 143 | ], 144 | "params-web": [ 145 | "$params", 146 | "params-web.php" 147 | ] 148 | ``` 149 | 150 | The config groups `params-console` and `params-web` will both contain the config values from `params.php` and `params-local.php` additional to their own configuration values. 151 | 152 | *** 153 | 154 | Define your configs like the following: 155 | 156 | ```php 157 | return [ 158 | 'components' => [ 159 | 'db' => [ 160 | 'class' => \my\Db::class, 161 | 'name' => $params['db.name'], 162 | 'password' => $params['db.password'], 163 | ], 164 | ], 165 | ]; 166 | ``` 167 | 168 | A special variable `$params` is read from config (by default, group is named `params`). 169 | 170 | ### Using custom group for `$params` 171 | 172 | By default, `$params` variable is read from `params` group. You can customize the group name via constructor of `Config`: 173 | 174 | ```php 175 | $config = new Config( 176 | new ConfigPaths(__DIR__ . '/configs'), 177 | null, 178 | [], 179 | 'custom-params' // Group name for `$params` 180 | ); 181 | ``` 182 | 183 | You can pass `null` as `$params` group name. In this case `$params` will empty array. 184 | 185 | ### Using sub-configs 186 | 187 | In order to access a sub-config, use the following in your config: 188 | 189 | ```php 190 | 'routes' => $config->get('routes'); 191 | ``` 192 | 193 | ## Options 194 | 195 | A number of options is available both for Composer plugin and a config loader. Composer options are specified in 196 | `composer.json`: 197 | 198 | ```json 199 | "extra": { 200 | "config-plugin-options": { 201 | "source-directory": "config" 202 | }, 203 | "config-plugin": { 204 | // ... 205 | } 206 | } 207 | ``` 208 | 209 | ### `source-directory` 210 | 211 | The `source-directory` option specifies where to read the configs from for a package the option is specified for. 212 | It is available for all packages, including the root package, which is typically an application. 213 | The value is a path relative to where the `composer.json` file is located. The default value is an empty string. 214 | 215 | If you change the source directory for the root package, don't forget to adjust configs path when creating 216 | an instance of `Config`. Usually that is `index.php`: 217 | 218 | ```php 219 | use Yiisoft\Config\Config; 220 | use Yiisoft\Config\ConfigPaths; 221 | 222 | $config = new Config( 223 | new ConfigPaths(dirname(__DIR__), 'path/to/config/directory'), 224 | ); 225 | 226 | $web = $config->get('web'); 227 | ``` 228 | 229 | ### `vendor-override-layer` 230 | 231 | The `vendor-override-layer` option adds a sublayer to the vendor, which allocates packages that will override 232 | the vendor's default configurations. This sublayer is located between the vendor and application layers. 233 | 234 | This can be useful if you need to redefine default configurations even before the application layer. To do this, 235 | you need to create your own package with configurations meant to override the default ones: 236 | 237 | ```json 238 | "name": "vendor-name/package-name", 239 | "extra": { 240 | "config-plugin": { 241 | // ... 242 | } 243 | } 244 | ``` 245 | 246 | And in the root file `composer.json` of your application, specify this package in the `vendor-override-layer` option: 247 | 248 | ```json 249 | "require": { 250 | "vendor-name/package-name": "version", 251 | "yiisoft/config": "version" 252 | }, 253 | "extra": { 254 | "config-plugin-options": { 255 | "vendor-override-layer": "vendor-name/package-name" 256 | }, 257 | "config-plugin": { 258 | // ... 259 | } 260 | } 261 | ``` 262 | 263 | In the same way, several packages can be added to this sublayer: 264 | 265 | ```json 266 | "extra": { 267 | "config-plugin-options": { 268 | "vendor-override-layer": [ 269 | "vendor-name/package-1", 270 | "vendor-name/package-2" 271 | ] 272 | } 273 | } 274 | ``` 275 | 276 | You can use wildcard pattern if there are too many packages: 277 | 278 | ```json 279 | "extra": { 280 | "config-plugin-options": { 281 | "vendor-override-layer": [ 282 | "vendor-1/*", 283 | "vendor-2/config-*" 284 | ] 285 | } 286 | } 287 | ``` 288 | 289 | For more information about the wildcard syntax, see the [yiisoft/strings](https://github.com/yiisoft/strings). 290 | 291 | > Please note that in this sublayer keys with the same names are not allowed similar to other layers. 292 | 293 | ### `merge-plan-file` 294 | 295 | This option allows you to override path to merge plan file. It is `.merge-plan.php` by default. To change it, set the value: 296 | 297 | ```json 298 | "extra": { 299 | "config-plugin-options": { 300 | "merge-plan-file": "custom/path/my-merge-plan.php" 301 | } 302 | } 303 | ``` 304 | 305 | This can be useful when developing. Don't forget to set same path in `Config` constructor when changing this option. 306 | 307 | ### `build-merge-plan` 308 | 309 | The `build-merge-plan` option allows you to disable creation/updating of the `config/.merge-plan.php`. 310 | Enabled by default, to disable it, set the value to `false`: 311 | 312 | ```json 313 | "extra": { 314 | "config-plugin-options": { 315 | "build-merge-plan": false 316 | } 317 | } 318 | ``` 319 | 320 | This can be useful when developing. If the config package is a dependency of your package, 321 | and you do not need to create a merge plan file when developing your package. 322 | For example, this is implemented in [yiisoft/yii-runner](https://github.com/yiisoft/yii-runner). 323 | 324 | ### `package-types` 325 | 326 | The `package-types` option define package types for process by composer plugin. By default, it is "library" and 327 | "composer-plugin". You can override default value by own types: 328 | 329 | ```json 330 | "extra": { 331 | "config-plugin-options": { 332 | "package-types": ["library", "my-extension"] 333 | } 334 | } 335 | ``` 336 | 337 | ## Environments 338 | 339 | The plugin supports creating additional environments added to the base configuration. This allows you to create 340 | multiple configurations for the application such as `production` and `development`. 341 | 342 | > Note that environments are supported on application level only and are not read from configurations of packages. 343 | 344 | The environments are specified in the `composer.json` file of your application: 345 | 346 | ```json 347 | "extra": { 348 | "config-plugin-options": { 349 | "source-directory": "config" 350 | }, 351 | "config-plugin": { 352 | "params": "params.php", 353 | "web": "web.php" 354 | }, 355 | "config-plugin-environments": { 356 | "dev": { 357 | "params": "dev/params.php", 358 | "app": [ 359 | "$web", 360 | "dev/app.php" 361 | ] 362 | }, 363 | "prod": { 364 | "app": "prod/app.php" 365 | } 366 | } 367 | } 368 | ``` 369 | 370 | Configuration defines the merge process. One of the environments from `config-plugin-environments` 371 | is merged with the main configuration defined by `config-plugin`. In given example, in the `dev` environment 372 | we use `$web` configuration from the main environment. 373 | 374 | This configuration has the following structure: 375 | 376 | ``` 377 | config/ Configuration root directory. 378 | dev/ Development environment files. 379 | app.php Development environment app group configuration. 380 | params.php Development environment parameters. 381 | prod/ Production environment files. 382 | app.php Production environment app group configuration. 383 | params.php Main configuration parameters. 384 | web.php Мain configuration web group configuration. 385 | ``` 386 | 387 | To choose an environment to be used you must specify its name when creating an instance of `Config`: 388 | 389 | ```php 390 | use Yiisoft\Config\Config; 391 | use Yiisoft\Config\ConfigPaths; 392 | 393 | $config = new Config( 394 | new ConfigPaths(dirname(__DIR__)), 395 | 'dev', 396 | ); 397 | 398 | $app = $config->get('app'); 399 | ``` 400 | 401 | If defined in an environment, `params` will be merged with `params` from the main configuration, 402 | and could be used as `$params` in all configurations. 403 | 404 | ## Configuration in a PHP file 405 | 406 | You can define configuration in a PHP file. To do it, specify a PHP file path in the `extra` section of 407 | the `composer.json`: 408 | 409 | ```json 410 | "extra": { 411 | "config-plugin-file": "path/to/configuration/file.php" 412 | } 413 | ``` 414 | 415 | Configurations are specified in the same way, only in PHP format: 416 | 417 | ```php 418 | return [ 419 | 'config-plugin-options' => [ 420 | 'source-directory' => 'config', 421 | ], 422 | 'config-plugin' => [ 423 | 'params' => [ 424 | 'params.php', 425 | '?params-local.php', 426 | ], 427 | 'web' => 'web.php', 428 | ], 429 | 'config-plugin-environments' => [ 430 | 'dev' => [ 431 | 'params' => 'dev/params.php', 432 | 'app' => [ 433 | '$web', 434 | 'dev/app.php', 435 | ], 436 | ], 437 | 'prod' => [ 438 | 'app' => 'prod/app.php', 439 | ], 440 | ], 441 | ]; 442 | ``` 443 | 444 | If you specify the file path, the remaining sections (`config-plugin-*`) in `composer.json` will be ignored and 445 | configurations will be read from the PHP file specified. The path is relative to where the `composer.json` file 446 | is located. 447 | 448 | ## Configuration modifiers 449 | 450 | ### Recursive merge of arrays 451 | 452 | By default, recursive merging of arrays in configuration files is not performed. If you want to recursively merge 453 | arrays in a certain group of configs, such as params, you must pass `RecursiveMerge` modifier with specified 454 | group names to the `Config` constructor: 455 | 456 | ```php 457 | use Yiisoft\Config\Config; 458 | use Yiisoft\Config\ConfigPaths; 459 | use Yiisoft\Config\Modifier\RecursiveMerge; 460 | 461 | $config = new Config( 462 | new ConfigPaths(dirname(__DIR__)), 463 | 'dev', 464 | [ 465 | RecursiveMerge::groups('params', 'events', 'events-web', 'events-console'), 466 | ], 467 | ); 468 | 469 | $params = $config->get('params'); // merged recursively 470 | ``` 471 | 472 | If you want to recursively merge arrays to a certain depth, use the `RecursiveMerge::groupsWithDepth()` method: 473 | 474 | ```php 475 | RecursiveMerge::groups(['widgets-themes', 'my-custom-group'], 1) 476 | ``` 477 | 478 | > Note: References to another configs use recursive modifier of root group. 479 | 480 | ### Reverse merge of arrays 481 | 482 | Result of reverse merge is being ordered descending by data source. It is useful for merging module config with 483 | base config where more specific config (i.e. module's) has more priority. One of such cases is merging events. 484 | 485 | To enable reverse merge pass `ReverseMerge` modifier with specified group names to the `Config` constructor: 486 | 487 | ```php 488 | use Yiisoft\Config\Config; 489 | use Yiisoft\Config\ConfigPaths; 490 | use Yiisoft\Config\Modifier\ReverseMerge; 491 | 492 | $config = new Config( 493 | new ConfigPaths(dirname(__DIR__)), 494 | 'dev', 495 | [ 496 | ReverseMerge::groups('events', 'events-web', 'events-console'), 497 | ], 498 | ); 499 | 500 | $events = $config->get('events-console'); // merged reversed 501 | ``` 502 | 503 | > Note: References to another configs use reverse modifier of root group. 504 | 505 | ### Remove elements from vendor package configuration 506 | 507 | Sometimes it is necessary to remove some elements of vendor packages configuration. To do this, 508 | pass `RemoveFromVendor` modifier to the `Config` constructor. 509 | 510 | Remove specified key paths: 511 | 512 | ```php 513 | use Yiisoft\Config\Config; 514 | use Yiisoft\Config\ConfigPaths; 515 | use Yiisoft\Config\Modifier\RemoveFromVendor; 516 | 517 | $config = new Config( 518 | new ConfigPaths(dirname(__DIR__)), 519 | 'dev', 520 | [ 521 | // Remove elements `key-for-remove` and `nested→key→for-remove` from all groups in all vendor packages 522 | RemoveFromVendor::keys( 523 | ['key-for-remove'], 524 | ['nested', 'key', 'for-remove'], 525 | ), 526 | 527 | // Remove elements `a` and `b` from all groups in package `yiisoft/auth` 528 | RemoveFromVendor::keys(['a'], ['b']) 529 | ->package('yiisoft/auth'), 530 | 531 | // Remove elements `c` and `d` from groups `params` and `web` in package `yiisoft/view` 532 | RemoveFromVendor::keys(['c'], ['d']) 533 | ->package('yiisoft/view', 'params', 'web'), 534 | 535 | // Remove elements `e` and `f` from all groups in package `yiisoft/auth` 536 | // and from groups `params` and `web` in package `yiisoft/view` 537 | RemoveFromVendor::keys(['e'], ['f']) 538 | ->package('yiisoft/auth') 539 | ->package('yiisoft/view', 'params', 'web'), 540 | ], 541 | ); 542 | 543 | $params = $config->get('params'); 544 | ``` 545 | 546 | Remove specified configuration groups: 547 | 548 | ```php 549 | use Yiisoft\Config\Config; 550 | use Yiisoft\Config\ConfigPaths; 551 | use Yiisoft\Config\Modifier\RemoveFromVendor; 552 | 553 | $config = new Config( 554 | new ConfigPaths(dirname(__DIR__)), 555 | 'dev', 556 | [ 557 | RemoveFromVendor::groups([ 558 | // Remove group `params` from all vendor packages 559 | '*' => 'params', 560 | 561 | // Remove groups `common` and `web` from all vendor packages 562 | '*' => ['common', 'web'], 563 | 564 | // Remove all groups from package `yiisoft/auth` 565 | 'yiisoft/auth' => '*', 566 | 567 | // Remove groups `params` from package `yiisoft/http` 568 | 'yiisoft/http' => 'params', 569 | 570 | // Remove groups `params` and `common` from package `yiisoft/view` 571 | 'yiisoft/view' => ['params', 'common'], 572 | ]), 573 | ], 574 | ); 575 | ``` 576 | 577 | ### Combine modifiers 578 | 579 | `Config` supports simultaneous use of several modifiers: 580 | 581 | ```php 582 | use Yiisoft\Config\Config; 583 | use Yiisoft\Config\ConfigPaths; 584 | use Yiisoft\Config\Modifier\RecursiveMerge; 585 | use Yiisoft\Config\Modifier\RemoveFromVendor; 586 | use Yiisoft\Config\Modifier\ReverseMerge; 587 | 588 | $config = new Config( 589 | new ConfigPaths(dirname(__DIR__)), 590 | 'dev', 591 | [ 592 | RecursiveMerge::groups('params', 'events', 'events-web', 'events-console'), 593 | ReverseMerge::groups('events', 'events-web', 'events-console'), 594 | RemoveFromVendor::keys( 595 | ['key-for-remove'], 596 | ['nested', 'key', 'for-remove'], 597 | ), 598 | ], 599 | ); 600 | ``` 601 | 602 | ## Commands 603 | 604 | ### `yii-config-copy` 605 | 606 | The plugin adds extra `yii-config-copy` command to Composer. It copies the package config files from the vendor 607 | to the config directory of the root package: 608 | 609 | ```shell 610 | composer yii-config-copy [target-path] [files] 611 | ``` 612 | 613 | Copies all config files of the `yiisoft/view` package: 614 | 615 | ```shell 616 | # To the `config` directory 617 | composer yii-config-copy yiisoft/view 618 | 619 | # To the `config/my/path` directory 620 | composer yii-config-copy yiisoft/view my/path 621 | ``` 622 | 623 | Copies the specified config files of the `yiisoft/view` package: 624 | 625 | ```shell 626 | # To the `config` directory 627 | composer yii-config-copy yiisoft/view / params.php web.php 628 | 629 | # To the `config/my/path` directory and without the file extension 630 | composer yii-config-copy yiisoft/view my/path params web 631 | ``` 632 | 633 | In order to avoid conflicts with file names, a prefix is added to the names of the copied files: 634 | `yiisoft-view-params.php`, `yiisoft-view-web.php`. 635 | 636 | ### `yii-config-rebuild` 637 | 638 | The `yii-config-rebuild` command updates merge plan file. This command may be used if you have added files or directories 639 | to the application configuration file structure and these were not specified in `composer.json` of the root package. 640 | In this case you need to add to the information about new files to `composer.json` of the root package by executing the 641 | command: 642 | 643 | ```shell 644 | composer yii-config-rebuild 645 | ``` 646 | 647 | ### `yii-config-info` 648 | 649 | The `yii-config-info` command displays application or package configuration details. 650 | 651 | ```shell 652 | composer yii-config-info 653 | composer yii-config-info yiisoft/widget 654 | ``` 655 | 656 | ## Documentation 657 | 658 | - [Internals](docs/internals.md) 659 | 660 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for 661 | that. You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 662 | 663 | ## License 664 | 665 | The Yii Config package is free software. It is released under the terms of the BSD License. 666 | Please see [`LICENSE`](./LICENSE.md) for more information. 667 | 668 | Maintained by [Yii Software](https://www.yiiframework.com/). 669 | 670 | ## Credits 671 | 672 | The plugin is heavily inspired by [Composer config plugin](https://github.com/yiisoft/composer-config-plugin) 673 | originally created by HiQDev () in 2016 and then adopted by Yii. 674 | 675 | ## Support the project 676 | 677 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 678 | 679 | ## Follow updates 680 | 681 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 682 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 683 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 684 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 685 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 686 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist": [ 3 | "Composer\\Command\\BaseCommand", 4 | "Composer\\Composer", 5 | "Composer\\EventDispatcher\\EventSubscriberInterface", 6 | "Composer\\Factory", 7 | "Composer\\IO\\IOInterface", 8 | "Composer\\Package\\BasePackage", 9 | "Composer\\Package\\CompletePackage", 10 | "Composer\\Package\\PackageInterface", 11 | "Composer\\Plugin\\Capability\\CommandProvider", 12 | "Composer\\Plugin\\Capable", 13 | "Composer\\Plugin\\CommandEvent", 14 | "Composer\\Plugin\\PluginEvents", 15 | "Composer\\Plugin\\PluginInterface", 16 | "Composer\\Script\\Event", 17 | "Composer\\Script\\ScriptEvents", 18 | "Composer\\Util\\Filesystem", 19 | "Symfony\\Component\\Console\\Input\\InputArgument", 20 | "Symfony\\Component\\Console\\Input\\InputInterface", 21 | "Symfony\\Component\\Console\\Output\\OutputInterface" 22 | ], 23 | "php-core-extensions": [ 24 | "Core", 25 | "date", 26 | "json", 27 | "pcre", 28 | "Phar", 29 | "Reflection", 30 | "SPL", 31 | "standard" 32 | ], 33 | "scan-files": [] 34 | } 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/config", 3 | "type": "composer-plugin", 4 | "description": "Composer plugin and a library for config assembling", 5 | "keywords": [ 6 | "composer", 7 | "config", 8 | "plugin" 9 | ], 10 | "homepage": "https://github.com/yiisoft/config", 11 | "license": "BSD-3-Clause", 12 | "support": { 13 | "issues": "https://github.com/yiisoft/config/issues?state=open", 14 | "source": "https://github.com/yiisoft/config", 15 | "forum": "https://www.yiiframework.com/forum/", 16 | "wiki": "https://www.yiiframework.com/wiki/", 17 | "irc": "ircs://irc.libera.chat:6697/yii", 18 | "chat": "https://t.me/yii3en" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "opencollective", 23 | "url": "https://opencollective.com/yiisoft" 24 | }, 25 | { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/yiisoft" 28 | } 29 | ], 30 | "require": { 31 | "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", 32 | "composer-plugin-api": "^2.0", 33 | "symfony/console": "^5.4.11|^6.0.11|^7", 34 | "yiisoft/arrays": "^3.0", 35 | "yiisoft/strings": "^2.0", 36 | "yiisoft/var-dumper": "^1.1" 37 | }, 38 | "require-dev": { 39 | "bamarni/composer-bin-plugin": "^1.8.2", 40 | "composer/composer": "^2.8.5", 41 | "phpunit/phpunit": "^10.5.44", 42 | "rector/rector": "^2.0.7", 43 | "roave/infection-static-analysis-plugin": "^1.35", 44 | "spatie/phpunit-watcher": "^1.24", 45 | "vimeo/psalm": "^5.26.1|^6.1" 46 | }, 47 | "config": { 48 | "sort-packages": true, 49 | "bump-after-update": "dev", 50 | "allow-plugins": { 51 | "bamarni/composer-bin-plugin": true, 52 | "composer/package-versions-deprecated": true, 53 | "infection/extension-installer": true 54 | } 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Yiisoft\\Config\\": "src" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Yiisoft\\Config\\Tests\\": "tests" 64 | } 65 | }, 66 | "extra": { 67 | "class": "Yiisoft\\Config\\Composer\\EventHandler", 68 | "bump-after-update": "dev", 69 | "bamarni-bin": { 70 | "bin-links": true, 71 | "target-directory": "tools", 72 | "forward-command": true 73 | } 74 | }, 75 | "scripts": { 76 | "test": "phpunit --testdox --no-interaction", 77 | "test-watch": "phpunit-watcher watch" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /psalm83-84.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 13 | __DIR__ . '/src', 14 | __DIR__ . '/tests', 15 | ]); 16 | 17 | // register a single rule 18 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 19 | 20 | // define sets of rules 21 | $rectorConfig->sets([ 22 | LevelSetList::UP_TO_PHP_80, 23 | ]); 24 | 25 | $rectorConfig->skip([ 26 | RemoveExtraParametersRector::class, 27 | ClosureToArrowFunctionRector::class, 28 | ]); 29 | }; 30 | -------------------------------------------------------------------------------- /roave-bc-check.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - '#\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass \"Composer\\Command\\BaseCommand\" could not be found in the located source#' 4 | - '#\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass \"Composer\\Plugin\\PluginInterface\" could not be found in the located source#' 5 | -------------------------------------------------------------------------------- /src/Command/ConfigCommandProvider.php: -------------------------------------------------------------------------------- 1 | setName('yii-config-copy') 30 | ->setDescription('Copying package configuration files to the application.') 31 | ->setHelp('This command copies the package configuration files from the vendor to the application.') 32 | ->addArgument('package', InputArgument::REQUIRED, 'Package') 33 | ->addArgument('target', InputArgument::OPTIONAL, 'Target directory', '/') 34 | ->addArgument('files', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Files') 35 | ; 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | /** @var string $package */ 41 | $package = $input->getArgument('package'); 42 | 43 | /** @var string $target */ 44 | $target = $input->getArgument('target'); 45 | 46 | /** @var string[] $selectedFileNames */ 47 | $selectedFileNames = $input->getArgument('files'); 48 | 49 | $builder = new PackageFilesProcess($this->requireComposer(), [$package]); 50 | 51 | $filesystem = new Filesystem(); 52 | $targetPath = $builder 53 | ->paths() 54 | ->absolute(trim($target, '\/')); 55 | $filesystem->ensureDirectoryExists($targetPath); 56 | $prefix = str_replace('/', '-', $package); 57 | 58 | foreach ($this->prepareFiles($builder->files(), $selectedFileNames) as $file) { 59 | $filename = str_replace('/', '-', $file->filename()); 60 | $filesystem->copy($file->absolutePath(), "$targetPath/$prefix-$filename"); 61 | } 62 | 63 | return 0; 64 | } 65 | 66 | /** 67 | * @param PackageFile[] $packageFiles 68 | * @param string[] $selectedFileNames 69 | * 70 | * @return PackageFile[] 71 | */ 72 | private function prepareFiles(array $packageFiles, array $selectedFileNames): array 73 | { 74 | if (empty($selectedFileNames)) { 75 | return $packageFiles; 76 | } 77 | 78 | $files = []; 79 | 80 | foreach ($selectedFileNames as $selectedFileName) { 81 | if (pathinfo($selectedFileName, PATHINFO_EXTENSION) === '') { 82 | $selectedFileName .= '.php'; 83 | } 84 | 85 | foreach ($packageFiles as $file) { 86 | if (substr($file->relativePath(), 0 - strlen($selectedFileName)) === $selectedFileName) { 87 | $files[] = $file; 88 | } 89 | } 90 | } 91 | 92 | return $files; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Command/InfoCommand.php: -------------------------------------------------------------------------------- 1 | setName('yii-config-info') 23 | ->addArgument('package', InputArgument::OPTIONAL); 24 | } 25 | 26 | protected function execute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $io = new SymfonyStyle($input, $output); 29 | $composer = $this->requireComposer(); 30 | 31 | $packageName = $input->getArgument('package'); 32 | if (is_string($packageName)) { 33 | $package = $composer->getRepositoryManager()->getLocalRepository()->findPackage($packageName, '*'); 34 | if ($package === null) { 35 | $io->error('Package "' . $packageName . '" not found.'); 36 | return 1; 37 | } 38 | return $this->vendorPackage($composer, $package, $io); 39 | } 40 | 41 | return $this->rootPackage($composer, $io); 42 | } 43 | 44 | private function vendorPackage(Composer $composer, BasePackage $package, SymfonyStyle $io): int 45 | { 46 | $settings = ConfigSettings::forVendorPackage($composer, $package); 47 | if (empty($settings->packageConfiguration())) { 48 | $io->writeln(''); 49 | $io->writeln('Configuration don\'t found in package "' . $package->getName() . '".'); 50 | return 0; 51 | } 52 | 53 | $io->title('Yii Config — Package "' . $package->getName() . '"'); 54 | 55 | $io->writeln('Source directory: ' . $settings->path() . '/' . $settings->options()->sourceDirectory()); 56 | 57 | $io->section('Configuration groups'); 58 | $this->writeConfiguration($io, $settings->packageConfiguration()); 59 | 60 | return 0; 61 | } 62 | 63 | private function rootPackage(Composer $composer, SymfonyStyle $io): int 64 | { 65 | $settings = ConfigSettings::forRootPackage($composer); 66 | $options = $settings->options(); 67 | $sourceDirectory = $settings->options()->sourceDirectory(); 68 | $mergePlanFilePath = $settings->path() . '/' 69 | . (empty($sourceDirectory) ? '' : ($sourceDirectory . '/')) 70 | . $options->mergePlanFile(); 71 | 72 | $io->title('Yii Config — Root Configuration'); 73 | 74 | $io->section('Options'); 75 | $io->table([], [ 76 | [ 77 | 'Build merge plan', 78 | $options->buildMergePlan() ? 'yes' : 'no', 79 | ], 80 | [ 81 | 'Merge plan file path', 82 | file_exists($mergePlanFilePath) 83 | ? '' . $mergePlanFilePath . '' 84 | : '' . $mergePlanFilePath . ' (not exists)', 85 | ], 86 | [ 87 | 'Package types', 88 | empty($options->packageTypes()) ? 'not set' : implode(', ', $options->packageTypes()), 89 | ], 90 | [ 91 | 'Source directory', 92 | $settings->path() . '/' . $options->sourceDirectory(), 93 | ], 94 | [ 95 | 'Vendor override layer packages', 96 | empty($options->vendorOverrideLayerPackages()) 97 | ? 'not set' 98 | : implode(', ', $options->vendorOverrideLayerPackages()), 99 | ], 100 | ]); 101 | 102 | $io->section('Configuration groups'); 103 | $this->writeConfiguration($io, $settings->packageConfiguration()); 104 | 105 | $io->section('Environments'); 106 | $environmentsConfiguration = $settings->environmentsConfiguration(); 107 | if (empty($environmentsConfiguration)) { 108 | $io->writeln('not set'); 109 | } else { 110 | $isFirst = true; 111 | foreach ($environmentsConfiguration as $environment => $groups) { 112 | if ($isFirst) { 113 | $isFirst = false; 114 | } else { 115 | $io->newLine(); 116 | } 117 | $io->write(' ' . $environment . ''); 118 | if (empty($groups)) { 119 | $io->writeln(' (empty)'); 120 | } else { 121 | $io->newLine(); 122 | $this->writeConfiguration($io, $groups, offset: 2, addSeparateLine: false); 123 | } 124 | } 125 | } 126 | 127 | return 0; 128 | } 129 | 130 | /** 131 | * @psalm-param array $configuration 132 | */ 133 | private function writeConfiguration( 134 | SymfonyStyle $io, 135 | array $configuration, 136 | int $offset = 1, 137 | bool $addSeparateLine = true, 138 | ): void { 139 | foreach ($configuration as $group => $values) { 140 | $this->writeGroup($io, $group, $values, $offset); 141 | if ($addSeparateLine) { 142 | $io->newLine(); 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * @param string|string[] $items 149 | */ 150 | private function writeGroup(SymfonyStyle $io, string $group, array|string $items, int $offset): void 151 | { 152 | $prefix = str_repeat(' ', $offset); 153 | $items = (array) $items; 154 | $io->write($prefix . '' . $group . ''); 155 | if (empty($items)) { 156 | $io->write(' (empty)'); 157 | } else { 158 | foreach ($items as $item) { 159 | $io->newLine(); 160 | $io->write($prefix . ' - ' . (Options::isVariable($item) ? '' . $item . '' : $item)); 161 | } 162 | } 163 | $io->newLine(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Command/RebuildCommand.php: -------------------------------------------------------------------------------- 1 | setName('yii-config-rebuild') 21 | ->setDescription('Crawls all the configuration files and updates the merge plan file.') 22 | ->setHelp('This command crawls all the configuration files and updates the merge plan file.') 23 | ; 24 | } 25 | 26 | protected function execute(InputInterface $input, OutputInterface $output): int 27 | { 28 | new MergePlanProcess($this->requireComposer()); 29 | return 0; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Composer/ConfigSettings.php: -------------------------------------------------------------------------------- 1 | > 14 | * @psalm-type EnvironmentsConfigurationType = array> 15 | */ 16 | final class ConfigSettings 17 | { 18 | private readonly Options $options; 19 | 20 | /** 21 | * @psalm-var PackageConfigurationType 22 | */ 23 | private readonly array $packageConfiguration; 24 | 25 | /** 26 | * @psalm-var EnvironmentsConfigurationType 27 | */ 28 | private readonly array $environmentsConfiguration; 29 | 30 | private function __construct( 31 | private readonly string $path, 32 | array $composerExtra, 33 | ) { 34 | if (isset($composerExtra['config-plugin-file'])) { 35 | /** 36 | * @var array $extra 37 | * @psalm-suppress UnresolvableInclude,MixedOperand 38 | */ 39 | $extra = require $this->path . '/' . $composerExtra['config-plugin-file']; 40 | } else { 41 | $extra = $composerExtra; 42 | } 43 | 44 | $this->options = new Options($extra); 45 | 46 | /** @psalm-var PackageConfigurationType */ 47 | $this->packageConfiguration = (array) ($extra['config-plugin'] ?? []); 48 | 49 | /** @psalm-var EnvironmentsConfigurationType */ 50 | $this->environmentsConfiguration = $extra['config-plugin-environments'] ?? []; 51 | } 52 | 53 | public static function forRootPackage(Composer $composer): self 54 | { 55 | /** @psalm-suppress PossiblyFalseArgument */ 56 | return new self( 57 | realpath(dirname(Factory::getComposerFile())), 58 | $composer->getPackage()->getExtra(), 59 | ); 60 | } 61 | 62 | public static function forVendorPackage(Composer $composer, BasePackage $package): self 63 | { 64 | /** 65 | * @var string $rootPath Because we use library and composer-plugins only which always has installation path. 66 | * @see PackagesListBuilder::getAllPackages() 67 | */ 68 | $rootPath = $composer->getInstallationManager()->getInstallPath($package); 69 | return new self($rootPath, $package->getExtra()); 70 | } 71 | 72 | public function path(): string 73 | { 74 | return $this->path; 75 | } 76 | 77 | public function configPath(): string 78 | { 79 | $sourceDirectory = $this->options->sourceDirectory(); 80 | return $this->path . (empty($sourceDirectory) ? '' : "/$sourceDirectory"); 81 | } 82 | 83 | public function options(): Options 84 | { 85 | return $this->options; 86 | } 87 | 88 | /** 89 | * Returns the root package configuration. 90 | * 91 | * @return array The root package configuration. 92 | * 93 | * @psalm-return PackageConfigurationType 94 | */ 95 | public function packageConfiguration(): array 96 | { 97 | return $this->packageConfiguration; 98 | } 99 | 100 | /** 101 | * Returns the environments configuration. 102 | * 103 | * @return array The environments configuration. 104 | * 105 | * @psalm-return EnvironmentsConfigurationType 106 | */ 107 | public function environmentsConfiguration(): array 108 | { 109 | return $this->environmentsConfiguration; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Composer/EventHandler.php: -------------------------------------------------------------------------------- 1 | 'onCommand', 31 | ScriptEvents::POST_AUTOLOAD_DUMP => 'onPostAutoloadDump', 32 | ScriptEvents::POST_INSTALL_CMD => 'onPostUpdateCommandDump', 33 | ScriptEvents::POST_UPDATE_CMD => 'onPostUpdateCommandDump', 34 | ]; 35 | } 36 | 37 | /** 38 | * @codeCoverageIgnore This method runs via eval and does not get into coverage. 39 | */ 40 | public function onCommand(CommandEvent $event): void 41 | { 42 | if ($event->getCommandName() === 'dump-autoload') { 43 | $this->runOnAutoloadDump = true; 44 | } 45 | } 46 | 47 | /** 48 | * @codeCoverageIgnore This method runs via eval and does not get into coverage. 49 | */ 50 | public function onPostAutoloadDump(Event $event): void 51 | { 52 | if ($this->runOnAutoloadDump) { 53 | $this->processConfigs($event->getComposer()); 54 | } 55 | } 56 | 57 | public function onPostUpdateCommandDump(Event $event): void 58 | { 59 | $this->processConfigs($event->getComposer()); 60 | } 61 | 62 | public function getCapabilities(): array 63 | { 64 | return [CommandProvider::class => ConfigCommandProvider::class]; 65 | } 66 | 67 | public function activate(Composer $composer, IOInterface $io): void 68 | { 69 | // do nothing 70 | } 71 | 72 | public function deactivate(Composer $composer, IOInterface $io): void 73 | { 74 | // do nothing 75 | } 76 | 77 | public function uninstall(Composer $composer, IOInterface $io): void 78 | { 79 | // do nothing 80 | } 81 | 82 | private function processConfigs(Composer $composer): void 83 | { 84 | new MergePlanProcess($composer); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Composer/MergePlanProcess.php: -------------------------------------------------------------------------------- 1 | mergePlan = new MergePlan(); 36 | $this->helper = new ProcessHelper($composer); 37 | 38 | if (!$this->helper->shouldBuildMergePlan()) { 39 | return; 40 | } 41 | 42 | $this->addPackagesConfigsToMergePlan(false); 43 | $this->addPackagesConfigsToMergePlan(true); 44 | 45 | $this->addRootPackageConfigToMergePlan(); 46 | $this->addEnvironmentsConfigsToMergePlan(); 47 | 48 | $this->updateMergePlan(); 49 | } 50 | 51 | private function addPackagesConfigsToMergePlan(bool $isVendorOverrideLayer): void 52 | { 53 | $packages = $isVendorOverrideLayer ? $this->helper->getVendorOverridePackages() : $this->helper->getVendorPackages(); 54 | 55 | foreach ($packages as $name => $package) { 56 | $configSettings = ConfigSettings::forVendorPackage($this->composer, $package); 57 | $packageName = $isVendorOverrideLayer ? Options::VENDOR_OVERRIDE_PACKAGE_NAME : $name; 58 | 59 | foreach ($configSettings->packageConfiguration() as $group => $files) { 60 | $this->mergePlan->addGroup($group); 61 | 62 | foreach ((array) $files as $file) { 63 | $isOptional = false; 64 | 65 | if (Options::isOptional($file)) { 66 | $isOptional = true; 67 | $file = substr($file, 1); 68 | } 69 | 70 | if (Options::isVariable($file)) { 71 | $this->mergePlan->add($file, $packageName, $group); 72 | continue; 73 | } 74 | 75 | $absoluteFilePath = $configSettings->configPath() . '/' . $file; 76 | 77 | if (Options::containsWildcard($file)) { 78 | $matches = glob($absoluteFilePath); 79 | 80 | if (empty($matches)) { 81 | continue; 82 | } 83 | 84 | foreach ($matches as $match) { 85 | $this->mergePlan->add( 86 | $this->normalizePackageFilePath($package, $match, $isVendorOverrideLayer), 87 | $packageName, 88 | $group, 89 | ); 90 | } 91 | 92 | continue; 93 | } 94 | 95 | if ($isOptional && !is_file($absoluteFilePath)) { 96 | continue; 97 | } 98 | 99 | $this->mergePlan->add( 100 | $this->normalizePackageFilePath($package, $absoluteFilePath, $isVendorOverrideLayer), 101 | $packageName, 102 | $group, 103 | ); 104 | } 105 | } 106 | } 107 | } 108 | 109 | private function addRootPackageConfigToMergePlan(): void 110 | { 111 | foreach ($this->helper->getRootPackageConfig() as $group => $files) { 112 | $this->mergePlan->addMultiple( 113 | (array) $files, 114 | Options::ROOT_PACKAGE_NAME, 115 | $group, 116 | ); 117 | } 118 | } 119 | 120 | private function addEnvironmentsConfigsToMergePlan(): void 121 | { 122 | foreach ($this->helper->getEnvironmentConfig() as $environment => $groups) { 123 | if ($environment === Options::DEFAULT_ENVIRONMENT) { 124 | continue; 125 | } 126 | 127 | if (empty($groups)) { 128 | $this->mergePlan->addEnvironmentWithoutConfigs($environment); 129 | continue; 130 | } 131 | 132 | foreach ($groups as $group => $files) { 133 | $this->mergePlan->addMultiple( 134 | (array) $files, 135 | Options::ROOT_PACKAGE_NAME, 136 | $group, 137 | $environment, 138 | ); 139 | } 140 | } 141 | } 142 | 143 | private function updateMergePlan(): void 144 | { 145 | $mergePlan = $this->mergePlan->toArray(); 146 | ksort($mergePlan); 147 | 148 | $filePath = $this->helper->getPaths()->absolute( 149 | $this->helper->getMergePlanFile() 150 | ); 151 | (new Filesystem())->ensureDirectoryExists(dirname($filePath)); 152 | 153 | /** @var string $oldContent */ 154 | $oldContent = is_file($filePath) ? file_get_contents($filePath) : ''; 155 | 156 | $content = 'export(true) . ";\n"; 160 | 161 | if ($this->normalizeLineEndings($oldContent) !== $this->normalizeLineEndings($content)) { 162 | file_put_contents($filePath, $content, LOCK_EX); 163 | } 164 | } 165 | 166 | private function normalizeLineEndings(string $value): string 167 | { 168 | return strtr($value, [ 169 | "\r\n" => "\n", 170 | "\r" => "\n", 171 | ]); 172 | } 173 | 174 | private function normalizePackageFilePath( 175 | PackageInterface $package, 176 | string $absoluteFilePath, 177 | bool $isVendorOverrideLayer 178 | ): string { 179 | if ($isVendorOverrideLayer) { 180 | return $this->helper->getRelativePackageFilePathWithPackageName($package, $absoluteFilePath); 181 | } 182 | 183 | return $this->helper->getRelativePackageFilePath($package, $absoluteFilePath); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Composer/Options.php: -------------------------------------------------------------------------------- 1 | mergePlanFile = (string) $options['merge-plan-file']; 50 | } 51 | 52 | if (isset($options['build-merge-plan'])) { 53 | $this->buildMergePlan = (bool) $options['build-merge-plan']; 54 | } 55 | 56 | if (isset($options['vendor-override-layer'])) { 57 | $this->vendorOverrideLayerPackages = array_filter( 58 | (array) $options['vendor-override-layer'], 59 | static fn(mixed $value): bool => is_string($value), 60 | ); 61 | } 62 | 63 | if (isset($options['source-directory'])) { 64 | $this->sourceDirectory = $this->normalizePath((string) $options['source-directory']); 65 | } 66 | 67 | if (isset($options['package-types'])) { 68 | $this->packageTypes = array_filter( 69 | (array) $options['package-types'], 70 | static fn(mixed $value): bool => is_string($value), 71 | ); 72 | } 73 | } 74 | 75 | public static function containsWildcard(string $file): bool 76 | { 77 | return str_contains($file, '*'); 78 | } 79 | 80 | public static function isOptional(string $file): bool 81 | { 82 | return str_starts_with($file, '?'); 83 | } 84 | 85 | public static function isVariable(string $file): bool 86 | { 87 | return str_starts_with($file, '$'); 88 | } 89 | 90 | public function mergePlanFile(): string 91 | { 92 | return $this->mergePlanFile; 93 | } 94 | 95 | public function buildMergePlan(): bool 96 | { 97 | return $this->buildMergePlan; 98 | } 99 | 100 | /** 101 | * @return string[] 102 | */ 103 | public function vendorOverrideLayerPackages(): array 104 | { 105 | return $this->vendorOverrideLayerPackages; 106 | } 107 | 108 | public function sourceDirectory(): string 109 | { 110 | return $this->sourceDirectory; 111 | } 112 | 113 | /** 114 | * @return string[] 115 | */ 116 | public function packageTypes(): array 117 | { 118 | return $this->packageTypes; 119 | } 120 | 121 | private function normalizePath(string $value): string 122 | { 123 | return trim(str_replace('\\', '/', $value), '/'); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Composer/PackageFile.php: -------------------------------------------------------------------------------- 1 | filename = str_replace($configSettings->configPath() . '/', '', $absolutePath); 20 | $this->relativePath = str_replace($configSettings->path() . '/', '', $absolutePath); 21 | } 22 | 23 | public function filename(): string 24 | { 25 | return $this->filename; 26 | } 27 | 28 | public function relativePath(): string 29 | { 30 | return $this->relativePath; 31 | } 32 | 33 | public function absolutePath(): string 34 | { 35 | return $this->absolutePath; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Composer/PackageFilesProcess.php: -------------------------------------------------------------------------------- 1 | helper = new ProcessHelper($composer); 37 | $this->process($packageNames); 38 | } 39 | 40 | /** 41 | * Returns the processed package configuration files. 42 | * 43 | * @return PackageFile[] The processed package configuration files. 44 | */ 45 | public function files(): array 46 | { 47 | return $this->packageFiles; 48 | } 49 | 50 | /** 51 | * Returns the config paths instance. 52 | * 53 | * @return ConfigPaths The config paths instance. 54 | */ 55 | public function paths(): ConfigPaths 56 | { 57 | return $this->helper->getPaths(); 58 | } 59 | 60 | /** 61 | * @param string[] $packageNames The pretty package names to build. 62 | * If the array is empty, the files of all packages will be build. 63 | */ 64 | private function process(array $packageNames): void 65 | { 66 | foreach ($this->helper->getPackages() as $package) { 67 | $configSettings = ConfigSettings::forVendorPackage($this->composer, $package); 68 | foreach ($configSettings->packageConfiguration() as $files) { 69 | $files = (array) $files; 70 | 71 | foreach ($files as $file) { 72 | $isOptional = false; 73 | 74 | if (Options::isOptional($file)) { 75 | $isOptional = true; 76 | $file = substr($file, 1); 77 | } 78 | 79 | if ( 80 | Options::isVariable($file) || 81 | (!empty($packageNames) && !in_array($package->getPrettyName(), $packageNames, true)) 82 | ) { 83 | continue; 84 | } 85 | 86 | $absoluteFilePath = $configSettings->configPath() . '/' . $file; 87 | 88 | if (Options::containsWildcard($file)) { 89 | $matches = glob($absoluteFilePath); 90 | 91 | if (empty($matches)) { 92 | continue; 93 | } 94 | 95 | foreach ($matches as $match) { 96 | $this->packageFiles[] = new PackageFile($configSettings, $match); 97 | } 98 | 99 | continue; 100 | } 101 | 102 | if ($isOptional && !is_file($absoluteFilePath)) { 103 | continue; 104 | } 105 | 106 | $this->packageFiles[] = new PackageFile($configSettings, $absoluteFilePath); 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Composer/PackagesListBuilder.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function build(): array 31 | { 32 | $allPackages = $this->getAllPackages(); 33 | 34 | $packageDepths = []; 35 | $this->calculatePackageDepths($allPackages, $packageDepths, 0, $this->composer->getPackage(), true); 36 | 37 | $result = []; 38 | foreach ($this->getSortedPackageNames($packageDepths) as $name) { 39 | if (array_key_exists($name, $allPackages)) { 40 | $result[$name] = $allPackages[$name]; 41 | } 42 | } 43 | 44 | return $result; 45 | } 46 | 47 | /** 48 | * Get package names stable sorted by depth 49 | * 50 | * @param array $packageDepths 51 | * 52 | * @return string[] 53 | */ 54 | private function getSortedPackageNames(array $packageDepths): array 55 | { 56 | $n = 0; 57 | foreach ($packageDepths as $name => $depth) { 58 | $packageDepths[$name] = [$depth, ++$n]; 59 | } 60 | 61 | /** @psalm-var array $packageDepths */ 62 | 63 | uasort($packageDepths, static function (array $a, array $b) { 64 | $result = $a[0] <=> $b[0]; 65 | return $result === 0 ? $a[1] <=> $b[1] : $result; 66 | }); 67 | 68 | return array_keys($packageDepths); 69 | } 70 | 71 | /** 72 | * @param array $allPackages 73 | * @param array $packageDepths 74 | */ 75 | private function calculatePackageDepths( 76 | array $allPackages, 77 | array &$packageDepths, 78 | int $depth, 79 | PackageInterface $package, 80 | bool $includingDev 81 | ): void { 82 | $name = $package->getPrettyName(); 83 | 84 | $packageProcessed = array_key_exists($name, $packageDepths); 85 | 86 | if (!$packageProcessed || $packageDepths[$name] < $depth) { 87 | $packageDepths[$name] = $depth; 88 | } 89 | 90 | // Prevent infinite loop in case of circular dependencies 91 | if ($packageProcessed) { 92 | return; 93 | } 94 | 95 | ++$depth; 96 | 97 | $dependencies = $includingDev 98 | ? [...array_keys($package->getRequires()), ...array_keys($package->getDevRequires())] 99 | : array_keys($package->getRequires()); 100 | 101 | foreach ($dependencies as $dependency) { 102 | if (array_key_exists($dependency, $allPackages)) { 103 | $this->calculatePackageDepths($allPackages, $packageDepths, $depth, $allPackages[$dependency], false); 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | private function getAllPackages(): array 112 | { 113 | $packages = $this->composer 114 | ->getRepositoryManager() 115 | ->getLocalRepository() 116 | ->getPackages(); 117 | 118 | $result = []; 119 | foreach ($packages as $package) { 120 | if (!in_array($package->getType(), $this->packageTypes)) { 121 | continue; 122 | } 123 | 124 | $result[$package->getPrettyName()] = $package; 125 | } 126 | 127 | return $result; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Composer/ProcessHelper.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private readonly array $packages; 29 | 30 | /** 31 | * @param Composer $composer The composer instance. 32 | */ 33 | public function __construct( 34 | private readonly Composer $composer, 35 | ) { 36 | /** @psalm-suppress UnresolvableInclude, MixedOperand */ 37 | require_once $composer->getConfig()->get('vendor-dir') . '/autoload.php'; 38 | 39 | $this->appConfigSettings = ConfigSettings::forRootPackage($composer); 40 | 41 | $this->paths = new ConfigPaths( 42 | $this->appConfigSettings->path(), 43 | $this->appConfigSettings->options()->sourceDirectory(), 44 | ); 45 | $this->packages = (new PackagesListBuilder( 46 | $this->composer, 47 | $this->appConfigSettings->options()->packageTypes() 48 | ))->build(); 49 | } 50 | 51 | /** 52 | * Returns all vendor packages. 53 | * 54 | * @psalm-return array 55 | */ 56 | public function getPackages(): array 57 | { 58 | return $this->packages; 59 | } 60 | 61 | /** 62 | * Returns vendor packages without packages from the vendor override sublayer. 63 | * 64 | * @psalm-return array 65 | */ 66 | public function getVendorPackages(): array 67 | { 68 | $vendorPackages = []; 69 | 70 | foreach ($this->packages as $name => $package) { 71 | if (!$this->isVendorOverridePackage($name)) { 72 | $vendorPackages[$name] = $package; 73 | } 74 | } 75 | 76 | return $vendorPackages; 77 | } 78 | 79 | /** 80 | * Returns vendor packages only from the vendor override sublayer. 81 | * 82 | * @psalm-return array 83 | */ 84 | public function getVendorOverridePackages(): array 85 | { 86 | $vendorOverridePackages = []; 87 | 88 | foreach ($this->packages as $name => $package) { 89 | if ($this->isVendorOverridePackage($name)) { 90 | $vendorOverridePackages[$name] = $package; 91 | } 92 | } 93 | 94 | return $vendorOverridePackages; 95 | } 96 | 97 | /** 98 | * Returns the relative path to the package file including the source directory {@see Options::sourceDirectory()}. 99 | * 100 | * @param PackageInterface $package The package instance. 101 | * @param string $filePath The absolute path to the package file. 102 | * 103 | * @return string The relative path to the package file including the source directory. 104 | */ 105 | public function getRelativePackageFilePath(PackageInterface $package, string $filePath): string 106 | { 107 | return str_replace("{$this->getPackageRootDirectoryPath($package)}/", '', $filePath); 108 | } 109 | 110 | /** 111 | * Returns the relative path to the package file including the package name. 112 | * 113 | * @param PackageInterface $package The package instance. 114 | * @param string $filePath The absolute path to the package file. 115 | * 116 | * @return string The relative path to the package file including the package name. 117 | */ 118 | public function getRelativePackageFilePathWithPackageName(PackageInterface $package, string $filePath): string 119 | { 120 | return "{$package->getPrettyName()}/{$this->getRelativePackageFilePath($package, $filePath)}"; 121 | } 122 | 123 | /** 124 | * Returns the root package configuration. 125 | * 126 | * @return array The root package configuration. 127 | * 128 | * @psalm-return PackageConfigurationType 129 | */ 130 | public function getRootPackageConfig(): array 131 | { 132 | return $this->appConfigSettings->packageConfiguration(); 133 | } 134 | 135 | /** 136 | * Returns the environment configuration. 137 | * 138 | * @return array The environment configuration. 139 | * 140 | * @psalm-return EnvironmentsConfigurationType 141 | */ 142 | public function getEnvironmentConfig(): array 143 | { 144 | return $this->appConfigSettings->environmentsConfiguration(); 145 | } 146 | 147 | /** 148 | * Returns the config paths instance. 149 | * 150 | * @return ConfigPaths The config paths instance. 151 | */ 152 | public function getPaths(): ConfigPaths 153 | { 154 | return $this->paths; 155 | } 156 | 157 | /** 158 | * Checks whether to build a merge plan. 159 | * 160 | * @return bool Whether to build a merge plan. 161 | */ 162 | public function shouldBuildMergePlan(): bool 163 | { 164 | return $this->appConfigSettings->options()->buildMergePlan(); 165 | } 166 | 167 | /** 168 | * @return string The merge plan filepath. 169 | */ 170 | public function getMergePlanFile(): string 171 | { 172 | return $this->appConfigSettings->options()->mergePlanFile(); 173 | } 174 | 175 | /** 176 | * Returns the absolute path to the package root directory. 177 | * 178 | * @param PackageInterface $package The package instance. 179 | * 180 | * @return string The absolute path to the package root directory. 181 | */ 182 | private function getPackageRootDirectoryPath(PackageInterface $package): string 183 | { 184 | /** 185 | * @var string Because we use library and composer-plugins only ({@see PackagesListBuilder::getAllPackages()}), 186 | * which always has installation path. 187 | */ 188 | return $this->composer 189 | ->getInstallationManager() 190 | ->getInstallPath($package); 191 | } 192 | 193 | /** 194 | * Checks whether the package is in the vendor override sublayer. 195 | * 196 | * @param string $package The package name. 197 | * 198 | * @return bool Whether the package is in the vendor override sublayer. 199 | */ 200 | private function isVendorOverridePackage(string $package): bool 201 | { 202 | foreach ($this->appConfigSettings->options()->vendorOverrideLayerPackages() as $pattern) { 203 | if ($package === $pattern || (new WildcardPattern($pattern))->match($package)) { 204 | return true; 205 | } 206 | } 207 | 208 | return false; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | private array $build = []; 32 | 33 | /** 34 | * @param ConfigPaths $paths The config paths instance. 35 | * @param string|null $environment The environment name. 36 | * @param object[] $modifiers Modifiers that affect merge process. 37 | * @param string|null $paramsGroup Group name for `$params`. If it is `null`, then `$params` will be empty array. 38 | * @param string $mergePlanFile The merge plan filepath. 39 | * 40 | * @throws ErrorException If the environment does not exist. 41 | */ 42 | public function __construct( 43 | ConfigPaths $paths, 44 | ?string $environment = null, 45 | array $modifiers = [], 46 | private readonly ?string $paramsGroup = 'params', 47 | string $mergePlanFile = Options::DEFAULT_MERGE_PLAN_FILE, 48 | ) { 49 | $environment = empty($environment) ? Options::DEFAULT_ENVIRONMENT : $environment; 50 | 51 | /** @psalm-suppress UnresolvableInclude, MixedArgument */ 52 | $mergePlan = new MergePlan(require $paths->absolute($mergePlanFile)); 53 | 54 | if (!$mergePlan->hasEnvironment($environment)) { 55 | $this->throwException(sprintf('The "%s" configuration environment does not exist.', $environment)); 56 | } 57 | 58 | $dataModifiers = new DataModifiers($modifiers); 59 | $this->merger = new Merger($paths, $dataModifiers); 60 | $this->filesExtractor = new FilesExtractor($paths, $mergePlan, $dataModifiers, $environment); 61 | } 62 | 63 | /** 64 | * {@inheritDoc} 65 | * 66 | * @throws ErrorException If the group does not exist or an error occurred during the build. 67 | */ 68 | public function get(string $group): array 69 | { 70 | if (isset($this->build[$group])) { 71 | return $this->build[$group]; 72 | } 73 | 74 | $this->runBuildParams(); 75 | 76 | $this->merger->reset(); 77 | $this->build[$group] = $this->buildGroup($group); 78 | 79 | return $this->build[$group]; 80 | } 81 | 82 | public function has(string $group): bool 83 | { 84 | return $this->filesExtractor->hasGroup($group); 85 | } 86 | 87 | /** 88 | * @throws ErrorException If an error occurred during the build. 89 | */ 90 | private function runBuildParams(): void 91 | { 92 | if ($this->paramsGroup !== null && !isset($this->build[$this->paramsGroup])) { 93 | $this->isBuildingParams = true; 94 | $this->build[$this->paramsGroup] = $this->buildGroup($this->paramsGroup); 95 | $this->isBuildingParams = false; 96 | } 97 | } 98 | 99 | /** 100 | * Builds the configuration of the group. 101 | * 102 | * @param string $group The group name. 103 | * 104 | * @throws ErrorException If an error occurred during the build. 105 | */ 106 | private function buildGroup(string $group, array $result = [], ?string $originalGroup = null): array 107 | { 108 | foreach ($this->filesExtractor->extract($group) as $file => $context) { 109 | if ($context->isVariable()) { 110 | $variable = $this->prepareVariable($file, $group); 111 | $result = $this->buildGroup($variable, $result, $originalGroup ?? $group); 112 | } else { 113 | $result = $this->merger->merge( 114 | $context->setOriginalGroup($originalGroup ?? $group), 115 | $result, 116 | $this->buildFile($file), 117 | ); 118 | } 119 | } 120 | 121 | return $result; 122 | } 123 | 124 | /** 125 | * Checks the configuration variable and returns its name. 126 | * 127 | * @param string $variable The variable. 128 | * @param string $group The group name. 129 | * 130 | * @throws ErrorException If the variable name is not valid. 131 | * 132 | * @return string The variable name. 133 | */ 134 | private function prepareVariable(string $variable, string $group): string 135 | { 136 | $name = substr($variable, 1); 137 | 138 | if ($name === $group) { 139 | $this->throwException(sprintf( 140 | 'The variable "%s" must not be located inside the "%s" config group.', 141 | "$variable", 142 | "$name", 143 | )); 144 | } 145 | 146 | return $name; 147 | } 148 | 149 | /** 150 | * Builds the configuration from the file. 151 | * 152 | * @param string $filePath The file path. 153 | * 154 | * @throws ErrorException If an error occurred during the build. 155 | * 156 | * @return array The configuration from the file. 157 | */ 158 | private function buildFile(string $filePath): array 159 | { 160 | $scopeRequire = static function (): array { 161 | /** @psalm-suppress InvalidArgument, MissingClosureParamType */ 162 | set_error_handler(static function (int $errorNumber, string $errorString, string $errorFile, int $errorLine) { 163 | throw new ErrorException($errorString, $errorNumber, 0, $errorFile, $errorLine); 164 | }); 165 | 166 | /** @psalm-suppress MixedArgument, PossiblyFalseArgument */ 167 | extract(func_get_arg(1), EXTR_SKIP); 168 | 169 | /** 170 | * @psalm-suppress UnresolvableInclude 171 | * @psalm-var array 172 | */ 173 | $result = require func_get_arg(0); 174 | restore_error_handler(); 175 | return $result; 176 | }; 177 | 178 | $scope = []; 179 | 180 | if (!$this->isBuildingParams) { 181 | $scope['config'] = $this; 182 | $scope['params'] = $this->paramsGroup === null ? [] : $this->build[$this->paramsGroup]; 183 | } 184 | 185 | /** @psalm-suppress TooManyArguments */ 186 | return $scopeRequire($filePath, $scope); 187 | } 188 | 189 | /** 190 | * @throws ErrorException 191 | */ 192 | private function throwException(string $message): void 193 | { 194 | throw new ErrorException($message, 0, E_USER_ERROR); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/ConfigInterface.php: -------------------------------------------------------------------------------- 1 | configPath = $rootPath . ($configDirectory === '' ? '' : "/$configDirectory"); 33 | 34 | $vendorDirectory = trim($vendorDirectory ?? Options::DEFAULT_VENDOR_DIRECTORY, '/'); 35 | $this->vendorPath = $rootPath . ($vendorDirectory === '' ? '' : "/$vendorDirectory"); 36 | } 37 | 38 | /** 39 | * Returns the absolute path to the configuration file. 40 | * 41 | * @param string $file Config file. 42 | * @param string $package Name of the package. {@see Options::ROOT_PACKAGE_NAME} stands for the root package. 43 | * 44 | * @return string The absolute path to the configuration file. 45 | */ 46 | public function absolute(string $file, string $package = Options::ROOT_PACKAGE_NAME): string 47 | { 48 | if ($package === Options::ROOT_PACKAGE_NAME) { 49 | return "$this->configPath/$file"; 50 | } 51 | 52 | if ($package === Options::VENDOR_OVERRIDE_PACKAGE_NAME) { 53 | return "$this->vendorPath/$file"; 54 | } 55 | 56 | return "$this->vendorPath/$package/$file"; 57 | } 58 | 59 | /** 60 | * Returns the relative path to the configuration file. 61 | * 62 | * @param string $file Config file. 63 | * 64 | * @return string The relative path to the configuration file. 65 | */ 66 | public function relative(string $file): string 67 | { 68 | return str_starts_with($file, "$this->rootPath/") 69 | ? substr($file, strlen("$this->rootPath/")) 70 | : $file; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | originalGroup = $group; 31 | return $this; 32 | } 33 | 34 | public function originalGroup(): string 35 | { 36 | return $this->originalGroup; 37 | } 38 | 39 | public function group(): string 40 | { 41 | return $this->group; 42 | } 43 | 44 | public function package(): string 45 | { 46 | return $this->package; 47 | } 48 | 49 | public function layer(): int 50 | { 51 | return $this->layer; 52 | } 53 | 54 | public function file(): string 55 | { 56 | return $this->file; 57 | } 58 | 59 | public function isVariable(): bool 60 | { 61 | return $this->isVariable; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/DataModifiers.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private readonly array $mergedGroupsRecursionDepthMap; 27 | 28 | /** 29 | * @psalm-var array 30 | */ 31 | private readonly array $reverseMergeGroupsIndex; 32 | 33 | /** 34 | * @psalm-var array 35 | */ 36 | private array $removeFromVendorGroupsIndex = []; 37 | 38 | /** 39 | * @psalm-var array> 40 | */ 41 | private array $removeFromVendorKeysIndex = []; 42 | 43 | /** 44 | * @param object[] $modifiers Modifiers that affect merge process. 45 | */ 46 | public function __construct(array $modifiers = []) 47 | { 48 | $reverseMergeGroups = []; 49 | $recursiveMergeGroups = []; 50 | 51 | foreach ($modifiers as $modifier) { 52 | if ($modifier instanceof ReverseMerge) { 53 | array_unshift($reverseMergeGroups, $modifier->getGroups()); 54 | continue; 55 | } 56 | 57 | if ($modifier instanceof RecursiveMerge) { 58 | array_unshift( 59 | $recursiveMergeGroups, 60 | array_fill_keys($modifier->getGroups(), $modifier->getDepth()) 61 | ); 62 | continue; 63 | } 64 | 65 | if ($modifier instanceof RemoveGroupsFromVendor) { 66 | foreach ($modifier->getGroups() as $package => $groups) { 67 | foreach ($groups as $group) { 68 | $this->removeFromVendorGroupsIndex[$package . '~' . $group] = true; 69 | } 70 | } 71 | continue; 72 | } 73 | 74 | if ($modifier instanceof RemoveKeysFromVendor) { 75 | $configPaths = []; 76 | 77 | if ($modifier->getPackages() === []) { 78 | $configPaths[] = '*'; 79 | } else { 80 | foreach ($modifier->getPackages() as $configPath) { 81 | $package = array_shift($configPath); 82 | 83 | if ($configPath === []) { 84 | $configPaths[] = $package . '~*'; 85 | } else { 86 | foreach ($configPath as $group) { 87 | $configPaths[] = $package . '~' . $group; 88 | } 89 | } 90 | } 91 | } 92 | 93 | foreach ($modifier->getKeys() as $keyPath) { 94 | foreach ($configPaths as $configPath) { 95 | $this->removeFromVendorKeysIndex[$configPath] ??= []; 96 | ArrayHelper::setValue($this->removeFromVendorKeysIndex[$configPath], $keyPath, true); 97 | } 98 | } 99 | } 100 | } 101 | 102 | $this->reverseMergeGroupsIndex = array_flip(array_merge(...$reverseMergeGroups)); 103 | $this->mergedGroupsRecursionDepthMap = array_merge(...$recursiveMergeGroups); 104 | } 105 | 106 | /** 107 | * @return false|int|null 108 | * - `int` - depth limited by specified number. 109 | * - `null` - depth is not limited (infinite recursion). 110 | * - `false` - recursion is disabled. 111 | */ 112 | public function getRecursionDepth(string $group): int|null|false 113 | { 114 | if (!array_key_exists($group, $this->mergedGroupsRecursionDepthMap)) { 115 | return false; 116 | } 117 | 118 | return $this->mergedGroupsRecursionDepthMap[$group]; 119 | } 120 | 121 | public function isReverseMergeGroup(string $group): bool 122 | { 123 | return array_key_exists($group, $this->reverseMergeGroupsIndex); 124 | } 125 | 126 | public function shouldRemoveGroupFromVendor(string $package, string $group, int $layer): bool 127 | { 128 | if ($layer !== Context::VENDOR) { 129 | return false; 130 | } 131 | 132 | return array_key_exists('*~*', $this->removeFromVendorGroupsIndex) 133 | || array_key_exists('*~' . $group, $this->removeFromVendorGroupsIndex) 134 | || array_key_exists($package . '~*', $this->removeFromVendorGroupsIndex) 135 | || array_key_exists($package . '~' . $group, $this->removeFromVendorGroupsIndex); 136 | } 137 | 138 | /** 139 | * @psalm-param non-empty-array $keyPath 140 | */ 141 | public function shouldRemoveKeyFromVendor(Context $context, array $keyPath): bool 142 | { 143 | if ($context->layer() !== Context::VENDOR) { 144 | return false; 145 | } 146 | 147 | $configPaths = [ 148 | '*', 149 | $context->package() . '~*', 150 | $context->package() . '~' . $context->group(), 151 | ]; 152 | 153 | foreach ($configPaths as $configPath) { 154 | if (ArrayHelper::getValue($this->removeFromVendorKeysIndex[$configPath] ?? [], $keyPath) === true) { 155 | return true; 156 | } 157 | } 158 | 159 | return false; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/FilesExtractor.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | public function extract(string $group): array 40 | { 41 | $environment = $this->prepareEnvironment($group); 42 | 43 | $result = $this->process(Options::DEFAULT_ENVIRONMENT, $group, $this->mergePlan->getGroup($group)); 44 | 45 | if ($environment !== Options::DEFAULT_ENVIRONMENT) { 46 | $result = array_merge( 47 | $result, 48 | $this->process( 49 | $environment, 50 | $group, 51 | $this->mergePlan->getGroup($group, $environment) 52 | ) 53 | ); 54 | } 55 | 56 | return $result; 57 | } 58 | 59 | /** 60 | * Checks whether the group exists in the merge plan. 61 | * 62 | * @param string $group The group name. 63 | * 64 | * @return bool Whether the group exists in the merge plan. 65 | */ 66 | public function hasGroup(string $group): bool 67 | { 68 | return $this->mergePlan->hasGroup($group, $this->environment) || ( 69 | $this->environment !== Options::DEFAULT_ENVIRONMENT && 70 | $this->mergePlan->hasGroup($group, Options::DEFAULT_ENVIRONMENT) 71 | ); 72 | } 73 | 74 | /** 75 | * @psalm-param array $data 76 | * 77 | * @throws ErrorException If an error occurred during the process. 78 | * 79 | * @psalm-return array 80 | */ 81 | private function process(string $environment, string $group, array $data): array 82 | { 83 | $result = []; 84 | 85 | foreach ($data as $package => $items) { 86 | $layer = $this->detectLayer($environment, $package); 87 | 88 | if ($this->dataModifiers->shouldRemoveGroupFromVendor($package, $group, $layer)) { 89 | continue; 90 | } 91 | 92 | foreach ($items as $item) { 93 | if (Options::isVariable($item)) { 94 | $result[$item] = new Context($group, $package, $layer, $item, true); 95 | continue; 96 | } 97 | 98 | $isOptional = Options::isOptional($item); 99 | 100 | if ($isOptional) { 101 | $item = substr($item, 1); 102 | } 103 | 104 | $filePath = $this->paths->absolute($item, $package); 105 | /** @psalm-var list $files */ 106 | $files = Options::containsWildcard($item) ? glob($filePath) : [$filePath]; 107 | 108 | foreach ($files as $file) { 109 | if (is_file($file)) { 110 | $result[$file] = new Context($group, $package, $layer, $file, false); 111 | } elseif (!$isOptional) { 112 | $this->throwException(sprintf('The "%s" file does not found.', $file)); 113 | } 114 | } 115 | } 116 | } 117 | 118 | return $result; 119 | } 120 | 121 | /** 122 | * Calculates the layer for the context. 123 | * 124 | * @param string $environment The environment name. 125 | * @param string $package The package name. 126 | * 127 | * @return int The layer for the context. 128 | */ 129 | private function detectLayer(string $environment, string $package): int 130 | { 131 | if ($package !== Options::ROOT_PACKAGE_NAME) { 132 | return $package === Options::VENDOR_OVERRIDE_PACKAGE_NAME ? Context::VENDOR_OVERRIDE : Context::VENDOR; 133 | } 134 | 135 | if ($environment === Options::DEFAULT_ENVIRONMENT) { 136 | return Context::APPLICATION; 137 | } 138 | 139 | return Context::ENVIRONMENT; 140 | } 141 | 142 | /** 143 | * Checks the group name and returns actual environment name. 144 | * 145 | * @param string $group The group name. 146 | * 147 | * @throws ErrorException If the group does not exist. 148 | * 149 | * @return string The actual environment name. 150 | */ 151 | private function prepareEnvironment(string $group): string 152 | { 153 | if (!$this->mergePlan->hasGroup($group, $this->environment)) { 154 | if ( 155 | $this->environment === Options::DEFAULT_ENVIRONMENT || 156 | !$this->mergePlan->hasGroup($group, Options::DEFAULT_ENVIRONMENT) 157 | ) { 158 | $this->throwException(sprintf('The "%s" configuration group does not exist.', $group)); 159 | } 160 | 161 | return Options::DEFAULT_ENVIRONMENT; 162 | } 163 | 164 | return $this->environment; 165 | } 166 | 167 | /** 168 | * @throws ErrorException 169 | */ 170 | private function throwException(string $message): void 171 | { 172 | throw new ErrorException($message, 0, E_USER_ERROR); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/MergePlan.php: -------------------------------------------------------------------------------- 1 | >> $mergePlan 16 | */ 17 | public function __construct( 18 | private array $mergePlan = [], 19 | ) { 20 | } 21 | 22 | /** 23 | * Adds an item to the merge plan. 24 | * 25 | * @param string $file The config file. 26 | * @param string $package The package name. 27 | * @param string $group The group name. 28 | * @param string $environment The environment name. 29 | */ 30 | public function add( 31 | string $file, 32 | string $package, 33 | string $group, 34 | string $environment = Options::DEFAULT_ENVIRONMENT 35 | ): void { 36 | $this->mergePlan[$environment][$group][$package][] = $file; 37 | } 38 | 39 | /** 40 | * Adds a multiple items to the merge plan. 41 | * 42 | * @param string[] $files The config files. 43 | * @param string $package The package name. 44 | * @param string $group The group name. 45 | * @param string $environment The environment name. 46 | */ 47 | public function addMultiple( 48 | array $files, 49 | string $package, 50 | string $group, 51 | string $environment = Options::DEFAULT_ENVIRONMENT 52 | ): void { 53 | $this->mergePlan[$environment][$group][$package] = $files; 54 | } 55 | 56 | /** 57 | * Adds an empty environment item to the merge plan. 58 | * 59 | * @param string $environment The environment name. 60 | */ 61 | public function addEnvironmentWithoutConfigs(string $environment): void 62 | { 63 | $this->mergePlan[$environment] = []; 64 | } 65 | 66 | /** 67 | * Add empty group if it not exists. 68 | * 69 | * @param string $group The group name. 70 | * @param string $environment The environment name. 71 | */ 72 | public function addGroup(string $group, string $environment = Options::DEFAULT_ENVIRONMENT): void 73 | { 74 | if (!isset($this->mergePlan[$environment][$group])) { 75 | $this->mergePlan[$environment][$group] = []; 76 | } 77 | } 78 | 79 | /** 80 | * Returns the merge plan group. 81 | * 82 | * @param string $group The group name. 83 | * @param string $environment The environment name. 84 | * 85 | * @return array 86 | */ 87 | public function getGroup(string $group, string $environment = Options::DEFAULT_ENVIRONMENT): array 88 | { 89 | return $this->mergePlan[$environment][$group] ?? []; 90 | } 91 | 92 | /** 93 | * Returns the merge plan as an array. 94 | * 95 | * @psalm-return array>> 96 | */ 97 | public function toArray(): array 98 | { 99 | return $this->mergePlan; 100 | } 101 | 102 | /** 103 | * Checks whether the group exists in the merge plan. 104 | * 105 | * @param string $group The group name. 106 | * @param string $environment The environment name. 107 | * 108 | * @return bool Whether the group exists in the merge plan. 109 | */ 110 | public function hasGroup(string $group, string $environment): bool 111 | { 112 | return isset($this->mergePlan[$environment][$group]); 113 | } 114 | 115 | /** 116 | * Checks whether the environment exists in the merge plan. 117 | * 118 | * @param string $environment The environment name. 119 | * 120 | * @return bool Whether the environment exists in the merge plan. 121 | */ 122 | public function hasEnvironment(string $environment): bool 123 | { 124 | return isset($this->mergePlan[$environment]); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Merger.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private array $cacheKeys = []; 29 | 30 | /** 31 | * @param ConfigPaths $configPaths The config paths instance. 32 | * @param DataModifiers $dataModifiers The data modifiers that affect merge process. 33 | */ 34 | public function __construct( 35 | private readonly ConfigPaths $configPaths, 36 | private readonly DataModifiers $dataModifiers, 37 | ) { 38 | } 39 | 40 | public function reset(): void 41 | { 42 | $this->cacheKeys = []; 43 | } 44 | 45 | /** 46 | * Merges two or more arrays into one recursively. 47 | * 48 | * @param Context $context Context containing the name of the file, package, group and environment. 49 | * @param array $arrayA First array to merge. 50 | * @param array $arrayB Second array to merge. 51 | * 52 | * @throws ErrorException If an error occurred during the merge. 53 | * 54 | * @return array The merged array. 55 | */ 56 | public function merge(Context $context, array $arrayA, array $arrayB): array 57 | { 58 | $recursionDepth = $this->dataModifiers->getRecursionDepth($context->originalGroup()); 59 | $isReverseMerge = $this->dataModifiers->isReverseMergeGroup($context->originalGroup()); 60 | 61 | if ($isReverseMerge) { 62 | $arrayB = $this->prepareArrayForReverse($context, [], $arrayB, $recursionDepth !== false); 63 | } 64 | 65 | return $this->performMerge( 66 | $context, 67 | [], 68 | $isReverseMerge ? $arrayB : $arrayA, 69 | $isReverseMerge ? $arrayA : $arrayB, 70 | $recursionDepth, 71 | $isReverseMerge, 72 | ); 73 | } 74 | 75 | /** 76 | * @param Context $context Context containing the name of the file, package, group and environment. 77 | * @param string[] $recursiveKeyPath The key path for recursive merging of arrays in configuration files. 78 | * @param array $arrayA First array to merge. 79 | * @param array $arrayB Second array to merge. 80 | * 81 | * @throws ErrorException If an error occurred during the merge. 82 | * 83 | * @return array The merged array. 84 | */ 85 | private function performMerge( 86 | Context $context, 87 | array $recursiveKeyPath, 88 | array $arrayA, 89 | array $arrayB, 90 | int|null|false $recursionDepth, 91 | bool $isReverseMerge, 92 | int $depth = 0, 93 | ): array { 94 | $result = $arrayA; 95 | foreach ($arrayB as $k => $v) { 96 | if (is_int($k)) { 97 | if (array_key_exists($k, $result) && $result[$k] !== $v) { 98 | $result[] = $v; 99 | } else { 100 | $result[$k] = $v; 101 | } 102 | continue; 103 | } 104 | 105 | $fullKeyPath = array_merge($recursiveKeyPath, [$k]); 106 | 107 | if ( 108 | $recursionDepth !== false 109 | && is_array($v) 110 | && ($recursionDepth === null || $depth < $recursionDepth) 111 | && ( 112 | !array_key_exists($k, $result) 113 | || is_array($result[$k]) 114 | ) 115 | ) { 116 | /** @var array $array */ 117 | $array = $result[$k] ?? []; 118 | $this->setValue( 119 | $context, 120 | $fullKeyPath, 121 | $result, 122 | $k, 123 | $this->performMerge( 124 | $context, 125 | $fullKeyPath, 126 | $array, 127 | $v, 128 | $recursionDepth, 129 | $isReverseMerge, 130 | $depth + 1, 131 | ) 132 | ); 133 | continue; 134 | } 135 | 136 | $existKey = array_key_exists($k, $result); 137 | 138 | if ($existKey && !$isReverseMerge) { 139 | /** @var string|null $file */ 140 | $file = ArrayHelper::getValue( 141 | $this->cacheKeys, 142 | array_merge([$context->layer()], $fullKeyPath) 143 | ); 144 | 145 | if ($file !== null) { 146 | $this->throwDuplicateKeyErrorException($context->originalGroup(), $fullKeyPath, [$file, $context->file()]); 147 | } 148 | } 149 | 150 | if (!$isReverseMerge || !$existKey) { 151 | $isSet = $this->setValue($context, $fullKeyPath, $result, $k, $v); 152 | 153 | if ($isSet && !$isReverseMerge && !$context->isVariable()) { 154 | /** @psalm-suppress MixedPropertyTypeCoercion */ 155 | ArrayHelper::setValue( 156 | $this->cacheKeys, 157 | array_merge([$context->layer()], $fullKeyPath), 158 | $context->file() 159 | ); 160 | } 161 | } 162 | } 163 | 164 | return $result; 165 | } 166 | 167 | /** 168 | * @param string[] $recursiveKeyPath 169 | * 170 | * @throws ErrorException If an error occurred during prepare. 171 | */ 172 | private function prepareArrayForReverse( 173 | Context $context, 174 | array $recursiveKeyPath, 175 | array $array, 176 | bool $isRecursiveMerge 177 | ): array { 178 | $result = []; 179 | 180 | foreach ($array as $key => $value) { 181 | if (is_int($key)) { 182 | $result[$key] = $value; 183 | continue; 184 | } 185 | 186 | if ($this->dataModifiers->shouldRemoveKeyFromVendor($context, array_merge($recursiveKeyPath, [$key]))) { 187 | continue; 188 | } 189 | 190 | if ($isRecursiveMerge && is_array($value)) { 191 | $result[$key] = $this->prepareArrayForReverse( 192 | $context, 193 | array_merge($recursiveKeyPath, [$key]), 194 | $value, 195 | true, 196 | ); 197 | continue; 198 | } 199 | 200 | $recursiveKeyPath[] = $key; 201 | 202 | /** @var string|null $file */ 203 | $file = ArrayHelper::getValue( 204 | $this->cacheKeys, 205 | array_merge([$context->layer()], $recursiveKeyPath) 206 | ); 207 | 208 | if ($file !== null) { 209 | $this->throwDuplicateKeyErrorException($context->originalGroup(), $recursiveKeyPath, [$file, $context->file()]); 210 | } 211 | 212 | $result[$key] = $value; 213 | 214 | /** @psalm-suppress MixedPropertyTypeCoercion */ 215 | ArrayHelper::setValue( 216 | $this->cacheKeys, 217 | array_merge([$context->layer()], $recursiveKeyPath), 218 | $context->file() 219 | ); 220 | } 221 | 222 | return $result; 223 | } 224 | 225 | /** 226 | * @psalm-param non-empty-array $keyPath 227 | */ 228 | private function setValue(Context $context, array $keyPath, array &$array, string $key, mixed $value): bool 229 | { 230 | if ($this->dataModifiers->shouldRemoveKeyFromVendor($context, $keyPath)) { 231 | return false; 232 | } 233 | 234 | /** @var mixed */ 235 | $array[$key] = $value; 236 | 237 | return true; 238 | } 239 | 240 | /** 241 | * Generates a duplicate key error message and throws an exception. 242 | * 243 | * @param string $currentGroupName The name of the group that the error occurred when merging. 244 | * @param string[] $recursiveKeyPath The key path for recursive merging of arrays in configuration files. 245 | * @param string[] $absoluteFilePaths The absolute paths to the files in which duplicates are found. 246 | * 247 | * @throws ErrorException With a duplicate key error message. 248 | */ 249 | private function throwDuplicateKeyErrorException( 250 | string $currentGroupName, 251 | array $recursiveKeyPath, 252 | array $absoluteFilePaths 253 | ): void { 254 | $filePaths = array_map( 255 | fn (string $filePath) => ' - ' . $this->configPaths->relative($filePath), 256 | $absoluteFilePaths, 257 | ); 258 | 259 | usort($filePaths, static function (string $a, string $b) { 260 | $countDirsA = substr_count($a, '/'); 261 | $countDirsB = substr_count($b, '/'); 262 | return $countDirsA === $countDirsB ? $a <=> $b : $countDirsA <=> $countDirsB; 263 | }); 264 | 265 | $message = sprintf( 266 | "Duplicate key \"%s\" in the following configs while building \"%s\" group:\n%s", 267 | implode(' => ', $recursiveKeyPath), 268 | $currentGroupName, 269 | implode("\n", $filePaths), 270 | ); 271 | 272 | throw new ErrorException($message, 0, E_USER_ERROR); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/Modifier/RecursiveMerge.php: -------------------------------------------------------------------------------- 1 | [ 31 | * 'a' => 1, 32 | * 'b' => 2, 33 | * ], 34 | * ]; 35 | * ``` 36 | * 37 | * - configuration in vendor package: 38 | * 39 | * ``` 40 | * "config-plugin": { 41 | * "params": "params.php", 42 | * } 43 | * ``` 44 | * 45 | * - vendor package `params.php` contents: 46 | * 47 | * ```php 48 | * return [ 49 | * 'key' => [ 50 | * 'c' => 3, 51 | * 'd' => 4, 52 | * ], 53 | * ]; 54 | * ``` 55 | * 56 | * - getting configuration: 57 | * 58 | * ```php 59 | * $config = new Config(new ConfigPaths($configsDir), null, [ 60 | * RecursiveMerge::groups('params') 61 | * ]); 62 | * 63 | * $result = $config->get('params'); 64 | * ``` 65 | * 66 | * The result will be: 67 | * 68 | * ```php 69 | * [ 70 | * 'key' => [ 71 | * 'c' => 3, 72 | * 'd' => 4, 73 | * 'a' => 1, 74 | * 'b' => 2, 75 | * ], 76 | * ] 77 | * ``` 78 | */ 79 | final class RecursiveMerge 80 | { 81 | /** 82 | * @param string[] $groups 83 | * @psalm-param positive-int|null $depth 84 | */ 85 | private function __construct( 86 | private readonly array $groups, 87 | private readonly ?int $depth = null, 88 | ) { 89 | } 90 | 91 | public static function groups(string ...$groups): self 92 | { 93 | return new self($groups); 94 | } 95 | 96 | /** 97 | * @param string[] $groups 98 | * @psalm-param positive-int|null $depth 99 | */ 100 | public static function groupsWithDepth(array $groups, ?int $depth): self 101 | { 102 | return new self($groups, $depth); 103 | } 104 | 105 | /** 106 | * @return string[] 107 | */ 108 | public function getGroups(): array 109 | { 110 | return $this->groups; 111 | } 112 | 113 | /** 114 | * @psalm-return positive-int|null 115 | */ 116 | public function getDepth(): ?int 117 | { 118 | return $this->depth; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Modifier/RemoveFromVendor.php: -------------------------------------------------------------------------------- 1 | package('yiisoft/auth'), 25 | * 26 | * // Remove elements `c` and `d` from groups `params` and `web` in package `yiisoft/view` 27 | * RemoveFromVendor::keys(['c'], ['d']) 28 | * ->package('yiisoft/view', 'params', 'web'), 29 | * 30 | * // Remove elements `e` and `f` from all groups in package `yiisoft/auth` 31 | * // and from groups `params` and `web` in package `yiisoft/view` 32 | * RemoveFromVendor::keys(['e'], ['f']) 33 | * ->package('yiisoft/auth') 34 | * ->package('yiisoft/view', 'params', 'web'), 35 | * ``` 36 | * 37 | * For example: 38 | * 39 | * - configuration in application `composer.json`: 40 | * 41 | * ``` 42 | * "config-plugin": { 43 | * "events": "events.php", 44 | * "params": "params.php", 45 | * } 46 | * ``` 47 | * 48 | * - application `events.php` contents: 49 | * 50 | * ```php 51 | * return ['a' => 1, 'b' => 2]; 52 | * ``` 53 | * 54 | * - configuration in vendor package: 55 | * 56 | * ``` 57 | * "config-plugin": { 58 | * "events": "events.php", 59 | * } 60 | * ``` 61 | * 62 | * - vendor package `events.php` contents: 63 | * 64 | * ```php 65 | * return ['c' => 3, 'd' => 4, 'e' => 5]; 66 | * ``` 67 | * 68 | * - getting configuration: 69 | * 70 | * ```php 71 | * $config = new Config(new ConfigPaths($configsDir), null, [ 72 | * RemoveFromVendor::keys( 73 | * ['d'], 74 | * ['e'], 75 | * ) 76 | * ]); 77 | * 78 | * $result = $config->get('events'); 79 | * ``` 80 | * 81 | * The result will be: 82 | * 83 | * ```php 84 | * [ 85 | * 'c' => 3, 86 | * 'a' => 1, 87 | * 'b' => 2, 88 | * ] 89 | * ``` 90 | * 91 | * @param string[] ...$keys 92 | */ 93 | public static function keys(array ...$keys): RemoveKeysFromVendor 94 | { 95 | return new RemoveKeysFromVendor(...$keys); 96 | } 97 | 98 | /** 99 | * Marks specified groups to be ignored when reading configuration from vendor packages. 100 | * 101 | * The modifier should be specified as 102 | * 103 | * ```php 104 | * RemoveFromVendor::groups([ 105 | * // Remove group `params` from all vendor packages 106 | * '*' => 'params', 107 | * 108 | * // Remove groups `common` and `web` from all vendor packages 109 | * '*' => ['common', 'web'], 110 | * 111 | * // Remove all groups from package `yiisoft/auth` 112 | * 'yiisoft/auth' => '*', 113 | * 114 | * // Remove groups `params` from package `yiisoft/http` 115 | * 'yiisoft/http' => 'params', 116 | * 117 | * // Remove groups `params` and `common` from package `yiisoft/view` 118 | * 'yiisoft/view' => ['params', 'common'], 119 | * ]), 120 | * ``` 121 | * 122 | * @psalm-param array $groups 123 | */ 124 | public static function groups(array $groups): RemoveGroupsFromVendor 125 | { 126 | return new RemoveGroupsFromVendor($groups); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Modifier/RemoveGroupsFromVendor.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private array $groups = []; 16 | 17 | /** 18 | * @psalm-param array $groups 19 | */ 20 | public function __construct(array $groups) 21 | { 22 | foreach ($groups as $package => $groupNames) { 23 | $this->groups[$package] = (array) $groupNames; 24 | } 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function getGroups(): array 31 | { 32 | return $this->groups; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Modifier/RemoveKeysFromVendor.php: -------------------------------------------------------------------------------- 1 | > 22 | */ 23 | private array $packages = []; 24 | 25 | /** 26 | * @param string[] ...$keys 27 | */ 28 | public function __construct(array ...$keys) 29 | { 30 | $this->keys = $keys; 31 | } 32 | 33 | public function package(string ...$package): self 34 | { 35 | if ($package === []) { 36 | throw new InvalidArgumentException('Package should be in format "packageName[, group][, group]".'); 37 | } 38 | $this->packages[] = $package; 39 | return $this; 40 | } 41 | 42 | /** 43 | * @return string[][] 44 | */ 45 | public function getKeys(): array 46 | { 47 | return $this->keys; 48 | } 49 | 50 | /** 51 | * @return string[][] 52 | * @psalm-return list> 53 | */ 54 | public function getPackages(): array 55 | { 56 | return $this->packages; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Modifier/ReverseMerge.php: -------------------------------------------------------------------------------- 1 | 1, 'b' => 2]; 32 | * ``` 33 | * 34 | * - configuration in vendor package: 35 | * 36 | * ``` 37 | * "config-plugin": { 38 | * "events": "events.php", 39 | * } 40 | * ``` 41 | * 42 | * - vendor package `events.php` contents: 43 | * 44 | * ```php 45 | * return ['c' => 3, 'd' => 4]; 46 | * ``` 47 | * 48 | * - getting configuration: 49 | * 50 | * ```php 51 | * $config = new Config(new ConfigPaths($configsDir), null, [ 52 | * ReverseMerge::groups('events'), 53 | * ]); 54 | * 55 | * $result = $config->get('events'); 56 | * ``` 57 | * 58 | * The result will be: 59 | * 60 | * ```php 61 | * [ 62 | * 'a' => 1, 63 | * 'b' => 2, 64 | * 'c' => 3, 65 | * 'd' => 4, 66 | * ] 67 | * ``` 68 | */ 69 | final class ReverseMerge 70 | { 71 | /** 72 | * @param string[] $groups 73 | */ 74 | private function __construct( 75 | private readonly array $groups, 76 | ) { 77 | } 78 | 79 | public static function groups(string ...$groups): self 80 | { 81 | return new self($groups); 82 | } 83 | 84 | /** 85 | * @return string[] 86 | */ 87 | public function getGroups(): array 88 | { 89 | return $this->groups; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | /*/vendor 2 | /*/composer.lock 3 | -------------------------------------------------------------------------------- /tools/composer-require-checker/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require-dev": { 3 | "maglnet/composer-require-checker": "^4.7.1" 4 | }, 5 | "config": { 6 | "bump-after-update": "dev" 7 | } 8 | } 9 | --------------------------------------------------------------------------------