{"id":16355416,"url":"https://github.com/alexskrypnyk/customizer","last_synced_at":"2025-10-05T23:32:41.939Z","repository":{"id":240450313,"uuid":"802670777","full_name":"AlexSkrypnyk/customizer","owner":"AlexSkrypnyk","description":"🎛 Interactive customization for template projects","archived":false,"fork":false,"pushed_at":"2025-08-11T18:31:05.000Z","size":185,"stargazers_count":1,"open_issues_count":6,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-08-20T22:05:12.734Z","etag":null,"topics":["boilerplate","composer","create-project","php","template"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/AlexSkrypnyk.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":"alexskrypnyk","patreon":"alexskrypnyk"}},"created_at":"2024-05-18T23:53:38.000Z","updated_at":"2025-08-11T13:26:23.000Z","dependencies_parsed_at":"2024-05-28T01:59:50.732Z","dependency_job_id":"0f670eec-a59b-4a54-8f31-cbf98d7d1aac","html_url":"https://github.com/AlexSkrypnyk/customizer","commit_stats":null,"previous_names":["alexskrypnyk/customizer"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/AlexSkrypnyk/customizer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexSkrypnyk%2Fcustomizer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexSkrypnyk%2Fcustomizer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexSkrypnyk%2Fcustomizer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexSkrypnyk%2Fcustomizer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/AlexSkrypnyk","download_url":"https://codeload.github.com/AlexSkrypnyk/customizer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AlexSkrypnyk%2Fcustomizer/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":271393754,"owners_count":24751769,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-20T02:00:09.606Z","response_time":69,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["boilerplate","composer","create-project","php","template"],"created_at":"2024-10-11T01:40:48.856Z","updated_at":"2025-10-05T23:32:36.894Z","avatar_url":"https://github.com/AlexSkrypnyk.png","language":"PHP","funding_links":["https://github.com/sponsors/alexskrypnyk","https://patreon.com/alexskrypnyk"],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003ca href=\"\" rel=\"noopener\"\u003e\n  \u003cimg width=100px height=100px src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Ctext x='50' y='65' text-anchor='middle' dominant-baseline='middle' font-size='100'%3E🎛%3C/text%3E%3C/svg%3E\" alt=\"Customizer logo\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eInteractive customization for template projects\u003c/h1\u003e\n\n\u003cdiv align=\"center\"\u003e\n\n[![GitHub Issues](https://img.shields.io/github/issues/AlexSkrypnyk/customizer.svg)](https://github.com/AlexSkrypnyk/customizer/issues)\n[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/AlexSkrypnyk/customizer.svg)](https://github.com/AlexSkrypnyk/customizer/pulls)\n[![Test PHP](https://github.com/AlexSkrypnyk/customizer/actions/workflows/test-php.yml/badge.svg)](https://github.com/AlexSkrypnyk/customizer/actions/workflows/test-php.yml)\n[![codecov](https://codecov.io/gh/AlexSkrypnyk/customizer/graph/badge.svg?token=7WEB1IXBYT)](https://codecov.io/gh/AlexSkrypnyk/customizer)\n![GitHub release (latest by date)](https://img.shields.io/github/v/release/AlexSkrypnyk/customizer)\n![LICENSE](https://img.shields.io/github/license/AlexSkrypnyk/customizer)\n![Renovate](https://img.shields.io/badge/renovate-enabled-green?logo=renovatebot)\n\n\u003c/div\u003e\n\n---\n\nThe Customizer allows template project authors to ask users questions during\nthe `composer create-project` command and then update the newly created project\nbased on the received answers.\n\n## TL;DR\n\nRun the command below to create a new project from the [template project example](https://github.com/AlexSkrypnyk/template-project-example)\nand see the Customizer in action:\n\n```bash\ncomposer create-project alexskrypnyk/template-project-example my-project\n```\n\n## Features\n\n- Simple installation into template project\n- Runs customization on `composer create-project`\n- Runs customization on `composer create-project --no-install` via `composer customize` command\n- Configuration file for questions and processing logic\n- Test harness for the template project to test questions and processing logic\n- No additional dependencies for minimal footprint\n\n## Installation\n\n1. Add to the template project as a Composer dependency:\n   ```json\n   \"require-dev\": {\n       \"alexskrypnyk/customizer\": \"^0.5\"\n   },\n   \"config\": {\n       \"allow-plugins\": {\n           \"alexskrypnyk/customizer\": true\n       }\n   }\n   ```\n   These entries will be removed by the Customizer after your project's users\n   run the `composer create-project` command.\n\n2. Copy [`customize.php`](customize.php) file with questions and processing\n   logic in any location within your template project and adjust it as needed.\n\nSee the [Configuration](#configuration) section below for more information.\n\n## Usage example\n\nWhen your users run the `composer create-project` command, the Customizer will\nask them questions and process the answers to customize their instance of the\ntemplate project.\n\nRun the command below to create a new project from the [template project example](https://github.com/AlexSkrypnyk/template-project-example)\nand see the Customizer in action:\n\n```bash\ncomposer create-project alexskrypnyk/template-project-example my-project\n```\n\nIn this example, the [demonstration questions](https://github.com/AlexSkrypnyk/template-project-example/blob/main/customize.php)\nwill ask you to provide a **package name**, **description**, and\n**license type**.  The answers are then processed by updating\nthe `composer.json` file and replacing the package name in other project files.\n\n### `--no-install`\n\nYour users may run the `composer create-project --no-install` command if they\nwant to adjust the project before installing dependencies, for example.\nCustomizer will not run in this case as it is not being installed yet and\nit's dependencies entries will stay in the `composer.json` file.\n\nThe user will have to run `composer customize` manually to run the\nCustomizer. It could be useful to let your users know about this command\nin your project's `README` file.\n\n## Configuration\n\nYou can configure how the Customizer processes your user’s template project by providing an arbitrary class (with any namespace) in a `customize.php` file. This includes defining questions and processing logic.\n\nThe class has to implement `public static` methods :\n- [`questions()`](#questions) - defines questions; required\n- [`process()`](#process) - defines processing logic based on received answers; required\n- [`cleanup()`](#cleanup) - defines processing logic for the `composer.json` file; optional\n- [`messages()`](#messages) - defines custom messages seen by the user; optional\n\n### `questions()`\n\nDefines **questions**, their **discovery** and **validation** callbacks.\nQuestions will be asked in the order they are defined. Questions can use answers\nfrom previous questions received so far.\n\nThe **discovery** callback is optional and runs before the question is asked. It\ncan be used to discover the default answer based on the current state of the\nproject. The discovered value is passed to the question callback. It can be an\nanonymous function or a method of the configuration class\nnamed `discover\u003cQuestionName\u003e`.\n\nThe **validation** callback should return the validated answer or throw an\nexception with a message to be shown to the user. This uses inbuilt\nSymfonyStyle's [`ask()`](https://symfony.com/doc/current/components/console/helpers/questionhelper.html#asking-the-user-for-information)\nmethod for asking questions.\n\n[`customize.php`](customize.php) has an example of the `questions()` method.\n\nNote that while the Customizer examples use SymfonyStyle's [`ask()`](https://symfony.com/doc/current/components/console/helpers/questionhelper.html#asking-the-user-for-information)\nmethod, you can build your own question asking logic using any other TUI\ninteraction methods. For example, you can use [Laravel Prompts](https://github.com/laravel/prompts).\n\n### `process()`\n\nDefines processing logic for all answers. This method will be called after all\nanswers are received and the user confirms the intended changes. It has access\nto all answers and Customizer's class public properties and methods.\n\nAll file manipulations should be done within this method.\n\n[`customize.php`](customize.php) has an example of the `process()` method.\n\n### `cleanup()`\n\nDefines the `cleanup()` method after all files were processed but before all\ndependencies are updated.\n\nThe Customizer will remove itself from the project and will update the\n`composer.json` as required. This method allows to alter that process as\nneeded and, if necessary, cancel the original self-cleanup.\n\n[`customize.php`](customize.php) has an example of the `cleanup()` method.\n\n### `messages()`\n\nDefines overrides for the Customizer's messages shown to the user.\n\n[`customize.php`](customize.php) has an example of the `messages()` method.\n\n### Example configuration\n\n\u003cdetails\u003e\n\u003csummary\u003eClick to expand an example configuration \u003ccode\u003ecustomize.php\u003c/code\u003e file\u003c/summary\u003e\n\n```php\n\u003c?php\n\ndeclare(strict_types=1);\n\nuse AlexSkrypnyk\\Customizer\\CustomizeCommand;\n\n/**\n * Customizer configuration.\n *\n * Example configuration for the Customizer command.\n *\n * phpcs:disable Drupal.Classes.ClassFileName.NoMatch\n */\nclass Customize {\n\n  /**\n   * A required callback with question definitions.\n   *\n   * Place questions into this method if you are using Customizer as a\n   * single-file drop-in for your scaffold project. Otherwise - place them into\n   * the configuration class.\n   *\n   * Any questions defined in the `questions()` method of the configuration\n   * class will **fully override** the questions defined here. This means that\n   * the configuration class must provide a full set of questions.\n   *\n   * See `customize.php` for an example of how to define questions.\n   *\n   * @return array\u003cstring,array\u003cstring,string|callable\u003e\u003e\n   *   An associative array of questions with question title as a key and the\n   *   value of array with the following keys:\n   *   - question: Required question callback function used to ask the question.\n   *     The callback receives the following arguments:\n   *     - discovered: A value discovered by the discover callback or NULL.\n   *     - answers: An associative array of all answers received so far.\n   *     - command: The CustomizeCommand object.\n   *   - discover: Optional callback function used to discover the value from\n   *     the environment. Can be an anonymous function or a method of this class\n   *     as discover\u003cPascalCasedQuestion\u003e. If not provided, empty string will\n   *     be passed to the question callback. The callback receives the following\n   *     arguments:\n   *     - command: The CustomizeCommand object.\n   */\n  public static function questions(CustomizeCommand $c): array {\n    // This an example of questions that can be asked to customize the project.\n    // You can adjust this method to ask questions that are relevant to your\n    // project.\n    //\n    // In this example, we ask for the package name, description, and license.\n    //\n    // You may remove all the questions below and replace them with your own.\n    return [\n      'Name' =\u003e [\n        // The discover callback function is used to discover the value from the\n        // environment. In this case, we use the current directory name\n        // and the GITHUB_ORG environment variable to generate the package name.\n        'discover' =\u003e static function (CustomizeCommand $c): string {\n          $name = basename((string) getcwd());\n          $org = getenv('GITHUB_ORG') ?: 'acme';\n\n          return $org . '/' . $name;\n        },\n        // The question callback function defines how the question is asked.\n        // In this case, we ask the user to provide a package name as a string.\n        // The discovery callback is used to provide a default value.\n        // The question callback provides a capability to validate the answer\n        // before it can be accepted by providing a validation callback.\n        'question' =\u003e static fn(string $discovered, array $answers, CustomizeCommand $c): mixed =\u003e $c-\u003eio-\u003eask('Package name', $discovered, static function (string $value): string {\n          // This is a validation callback that checks if the package name is\n          // valid. If not, an \\InvalidArgumentException exception is thrown\n          // with a message shown to the user.\n          if (!preg_match('/^[a-z0-9_.-]+\\/[a-z0-9_.-]+$/', $value)) {\n            throw new \\InvalidArgumentException(sprintf('The package name \"%s\" is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name.', $value));\n          }\n\n          return $value;\n        }),\n      ],\n      'Description' =\u003e [\n        // For this question, we use an answer from the previous question\n        // in the title of the question.\n        'question' =\u003e static fn(string $discovered, array $answers, CustomizeCommand $c): mixed =\u003e $c-\u003eio-\u003eask(sprintf('Description for %s', $answers['Name'])),\n      ],\n      'License' =\u003e [\n        // For this question, we use a pre-defined list of options.\n        // For discovery, we use a separate method named 'discoverLicense'\n        // (only for the demonstration purposes; it could have been an\n        // anonymous function).\n        'question' =\u003e static fn(string $discovered, array $answers, CustomizeCommand $c): mixed =\u003e $c-\u003eio-\u003echoice('License type',\n          [\n            'MIT',\n            'GPL-3.0-or-later',\n            'Apache-2.0',\n          ],\n          // Note that the default value is the value discovered by the\n          // 'discoverLicense' method. If the discovery did not return a value,\n          // the default value of 'GPL-3.0-or-later' is used.\n          empty($discovered) ? 'GPL-3.0-or-later' : $discovered\n        ),\n      ],\n    ];\n  }\n\n  /**\n   * A callback to discover the `License` value from the environment.\n   *\n   * This is an example of discovery function as a class method.\n   *\n   * @param \\AlexSkrypnyk\\Customizer\\CustomizeCommand $c\n   *   The Customizer instance.\n   */\n  public static function discoverLicense(CustomizeCommand $c): string {\n    return isset($c-\u003ecomposerjsonData['license']) \u0026\u0026 is_string($c-\u003ecomposerjsonData['license']) ? $c-\u003ecomposerjsonData['license'] : '';\n  }\n\n  /**\n   * A required callback to process all answers.\n   *\n   * This method is called after all questions have been answered and a user\n   * has confirmed the intent to proceed with the customization.\n   *\n   * Note that any manipulation of the composer.json file should be done here\n   * and then written back to the file system.\n   *\n   * @param array\u003cstring,string\u003e $answers\n   *   Gathered answers.\n   * @param \\AlexSkrypnyk\\Customizer\\CustomizeCommand $c\n   *   The Customizer instance.\n   */\n  public static function process(array $answers, CustomizeCommand $c): void {\n    $c-\u003edebug('Updating composer configuration');\n    $json = $c-\u003ereadComposerJson($c-\u003ecomposerjson);\n    $json['name'] = $answers['Name'];\n    $json['description'] = $answers['Description'];\n    $json['license'] = $answers['License'];\n    $c-\u003ewriteComposerJson($c-\u003ecomposerjson, $json);\n\n    $c-\u003edebug('Removing an arbitrary file.');\n    $files = $c-\u003efinder($c-\u003ecwd)-\u003efiles()-\u003ename('LICENSE');\n    foreach ($files as $file) {\n      $c-\u003efs-\u003eremove($file-\u003egetRealPath());\n    }\n  }\n\n  /**\n   * Cleanup after the customization.\n   *\n   * By the time this method is called, all the necessary changes have been made\n   * to the project.\n   *\n   * The Customizer will remove itself from the project and will update the\n   * composer.json as required. This method allows to alter that process as\n   * needed and, if necessary, cancel the original self-cleanup.\n   *\n   * @param \\AlexSkrypnyk\\Customizer\\CustomizeCommand $c\n   *   The CustomizeCommand object.\n   *\n   * @return bool\n   *   Return FALSE to skip the further self-cleanup. Returning TRUE will\n   *   proceed with the self-cleanup.\n   */\n  public static function cleanup(CustomizeCommand $c): bool {\n    if ($c-\u003eisComposerDependenciesInstalled) {\n      $c-\u003edebug('Add an example flag to composer.json.');\n      $json = $c-\u003ereadComposerJson($c-\u003ecomposerjson);\n      $json['extra'] = is_array($json['extra']) ? $json['extra'] : [];\n      $json['extra']['custom_field'] = TRUE;\n      $c-\u003ewriteComposerJson($c-\u003ecomposerjson, $json);\n    }\n\n    return TRUE;\n  }\n\n  /**\n   * Override some of the messages displayed to the user by Customizer.\n   *\n   * @param \\AlexSkrypnyk\\Customizer\\CustomizeCommand $c\n   *   The Customizer instance.\n   *\n   * @return array\u003cstring,string|array\u003cstring\u003e\u003e\n   *   An associative array of messages with message name as key and the message\n   *   test as a string or an array of strings.\n   */\n  public static function messages(CustomizeCommand $c): array {\n    return [\n      // This is an example of a custom message that overrides the default\n      // message with name `welcome`.\n      'title' =\u003e 'Welcome to the \"{{ package.name }}\" project customizer',\n    ];\n  }\n\n}\n```\n\u003c/details\u003e\n\n## Helpers\n\nThe Customizer provides a few helpers to make processing answers easier.\nThese are available as properties and methods of the Customizer instance\npassed to the processing callbacks:\n\n- `cwd` - current working directory.\n- `fs` - Symfony [`Filesystem`](https://symfony.com/doc/current/components/filesystem.html) instance.\n- `io` - Symfony [input/output](https://symfony.com/doc/current/console/style.html#helper-methods) instance.\n- `isComposerDependenciesInstalled` - whether the Composer dependencies were\n  installed before the Customizer started.\n- `readComposerJson()` - Read the contents of the `composer.json` file into an\n  array.\n- `writeComposerJson()` - Write the contents of the array to the `composer.json`\n  file.\n- `replaceInPath()` - Replace a string in a file or all files in a directory.\n- `replaceInPathBetweenMarkers()` - Replace a string in a file or all files in\n  a directory between two markers.\n- `uncommentLine()` - Uncomment a line in a file or all files in a directory.\n- `arrayUnsetDeep()` - Unset a fully or partially matched value in a nested\n  array, removing empty arrays.\n\nValidation helpers for questions are not provided in this class, but you can easily\ncreate them using custom regular expression or add them from the\n[AlexSkrypnyk/str2name](https://github.com/AlexSkrypnyk/Str2Name) package.\n\n## Developing and testing your questions\n\n### Testing manually\n\n1. Install the Customizer into your template project as described in the\n   [Installation](#installation) section.\n2. Create a new testing directory and change into it.\n3. Create a project in this directory:\n\n```bash\ncomposer create-project yournamespace/yourscaffold=\"@dev\" --repository '{\"type\": \"path\", \"url\": \"/path/to/yourscaffold\", \"options\": {\"symlink\": false}}' .\n```\n\n4. The Customizer screen should appear.\n\nRepeat the process as many times as needed to test your questions and processing\nlogic.\n\nAdd `export COMPOSER_ALLOW_XDEBUG=1` before running the `composer create-project`\ncommand to enable debugging with XDebug when running Composer commands.\n\n### Automated functional tests\n\nThe Customizer provides a [test harness](tests/phpunit/Functional) to help you, as a template project\nauthor, to test the questions and processing with ease.\n\nTo use the test harness:\n1. Setup PHPUnit in your template project to run tests.\n2. Inherit your test classes from [`CustomizerTestCase.php`](tests/phpunit/Functional/CustomizerTestCase.php) (this file is\n   included into distribution when you add Customizer to your template project).\n3. Add path to `CustomizerTestCase.php` into the `autoload-dev` section of your\n   template project's `composer.json` file:\n   ```json\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"AlexSkrypnyk\\\\Customizer\\\\Tests\\\\\": \"vendor/alexskrypnyk/customizer/tests/phpunit\"\n        }\n    },\n   ```\n4. Create a directory in your project with the name `tests/phpunit/Fixtures/\u003cname_of_test_snake_case\u003e`\n   and place your test fixtures there. If you use data providers, you can\n   create a sub-directory with the name of the data set within the provider (the top-level key within\n   the data provider).\n5. Add fixtures as _base_/_expected_ directory structures (see below) and assert for the\n   expected results in your test.\n\nSee examples within the [template project example](https://github.com/AlexSkrypnyk/template-project-example/blob/main/tests/phpunit).\n\n### Comparing fixture directories\n\nThe base test class [`CustomizerTestCase.php`](tests/phpunit/Functional/CustomizerTestCase.php) provides\nthe `assertFixtureDirectoryEqualsSut()` method to compare a directory under\ntest with the expected results.\n\nThe method uses _base_ and _expected_ directories to compare the results:\n_base_ is used as a state of the project you are testing before the\ncustomization ran, and _expected_ is used as an expected result, which will be\ncompared to the actual result after the customization.\n\nBecause the projects can have dependencies added during `composer install` and\nother files that are not related to the customization, the method allows you to\nspecify the list of files to ignore during the comparison using\n`.gitignore`-like syntax with the addition to ignore content changes but still\nassess the file presence.\n\nSee the description in `CustomizerTestCase::assertDirectoriesEqual()` for more\ninformation about the comparison process.\n\n## Maintenance\n\n    composer install   # Install dependencies.\n    composer lint      # Check coding standards.\n    composer lint-fix  # Fix coding standards.\n    composer test      # Run tests.\n\n---\n_This repository was created using the [getscaffold.dev](https://getscaffold.dev/) project scaffold template_\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexskrypnyk%2Fcustomizer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falexskrypnyk%2Fcustomizer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falexskrypnyk%2Fcustomizer/lists"}