├── docs ├── _index.md ├── changelog.md ├── installation.md ├── support-me.md ├── questions-and-issues.md ├── usage.md ├── introduction.md ├── configuration.md ├── metadata.md ├── commands.md ├── events.md ├── error-handling.md └── transactions.md ├── src ├── Events │ ├── PatchRollingBack.php │ ├── PatchRolledBack.php │ ├── PatchExecuting.php │ ├── PatchFailed.php │ └── PatchExecuted.php ├── Commands │ ├── stubs │ │ └── patch.stub │ ├── StatusCommand.php │ ├── ListCommand.php │ ├── RollbackCommand.php │ ├── PatchMakeCommand.php │ └── PatchCommand.php ├── LaravelPatchesServiceProvider.php ├── Models │ └── Patch.php ├── Patch.php ├── Repository.php └── Patcher.php ├── config └── laravel-patches.php ├── database └── migrations │ ├── create_patches_table.php.stub │ └── add_metadata_to_patches_table.php.stub ├── LICENSE.md ├── .php-cs-fixer.php ├── CHANGELOG.md ├── composer.json └── README.md /docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v3 3 | slogan: Run patches migration style in your Laravel applications. 4 | githubUrl: https://github.com/rappasoft/laravel-patches 5 | branch: master 6 | --- 7 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | weight: 4 4 | --- 5 | 6 | All notable changes to this package are documented [on GitHub](https://github.com/rappasoft/laravel-patches/blob/master/CHANGELOG.md). 7 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | weight: 5 4 | --- 5 | 6 | You can install the package via composer: 7 | 8 | ```bash 9 | composer require rappasoft/laravel-patches 10 | ``` 11 | 12 | -------------------------------------------------------------------------------- /docs/support-me.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Support Me 3 | weight: 2 4 | --- 5 | 6 | I invest a **lot** of time into creating [my packages](https://rappasoft.com/packages). You can support me by [sponsoring me on Github](https://github.com/sponsors/rappasoft). 7 | -------------------------------------------------------------------------------- /docs/questions-and-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Questions and Issues 3 | weight: 3 4 | --- 5 | 6 | If you have general questions not related to any issues you may post a [discussion](https://github.com/rappasoft/laravel-patches/discussions). 7 | 8 | If you have found a reproducable bug please create an [issue](https://github.com/rappasoft/laravel-patches/issues). 9 | 10 | If you've found a bug regarding security please mail anthony@rappasoft.com instead of using the issue tracker. 11 | -------------------------------------------------------------------------------- /src/Events/PatchRollingBack.php: -------------------------------------------------------------------------------- 1 | patch = $patch; 28 | $this->instance = $instance; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Events/PatchRolledBack.php: -------------------------------------------------------------------------------- 1 | patch = $patch; 28 | $this->executionTime = $executionTime; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Events/PatchExecuting.php: -------------------------------------------------------------------------------- 1 | patch = $patch; 33 | $this->batch = $batch; 34 | $this->instance = $instance; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/laravel-patches.php: -------------------------------------------------------------------------------- 1 | env('PATCHES_TABLE_NAME', 'patches'), 8 | 9 | /** 10 | * Transaction settings 11 | */ 12 | 'use_transactions' => env('PATCHES_USE_TRANSACTIONS', false), 13 | 14 | /** 15 | * Error handling 16 | */ 17 | 'stop_on_error' => env('PATCHES_STOP_ON_ERROR', true), 18 | 'log_errors' => env('PATCHES_LOG_ERRORS', true), 19 | 20 | /** 21 | * Metadata tracking 22 | */ 23 | 'track_metadata' => env('PATCHES_TRACK_METADATA', true), 24 | 'track_memory' => env('PATCHES_TRACK_MEMORY', true), 25 | 'track_user' => env('PATCHES_TRACK_USER', true), 26 | 27 | /** 28 | * Display settings 29 | */ 30 | 'show_descriptions' => env('PATCHES_SHOW_DESCRIPTIONS', true), 31 | ]; 32 | -------------------------------------------------------------------------------- /src/Commands/stubs/patch.stub: -------------------------------------------------------------------------------- 1 | log('Hello'); 18 | // $this->call('my-command', ['my-option' => 'my-value']); 19 | // $this->seed('PatchV1_0_0Seeder'); 20 | // $this->truncate('my-table'); 21 | } 22 | 23 | /** 24 | * Reverse the patch. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | info('Goodbye :('); 31 | 32 | // Useful methods 33 | // $this->call('my-command', ['my-option' => 'my-value']); 34 | // $this->seed('PatchV1_0_0Seeder'); 35 | // $this->truncate('my-table'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/migrations/create_patches_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('patch'); 19 | $table->integer('batch'); 20 | $table->json('log')->nullable(); 21 | $table->timestamp('ran_on'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists(config('laravel-patches.table_name')); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/LaravelPatchesServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-patches') 22 | ->hasConfigFile('laravel-patches') 23 | ->hasMigrations(['create_patches_table', 'add_metadata_to_patches_table']) 24 | ->hasCommands([ 25 | Commands\PatchMakeCommand::class, 26 | Commands\PatchCommand::class, 27 | Commands\RollbackCommand::class, 28 | Commands\StatusCommand::class, 29 | Commands\ListCommand::class, 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Events/PatchFailed.php: -------------------------------------------------------------------------------- 1 | patch = $patch; 39 | $this->batch = $batch; 40 | $this->exception = $exception; 41 | $this->executionTime = $executionTime; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Models/Patch.php: -------------------------------------------------------------------------------- 1 | |bool 23 | */ 24 | protected $guarded = []; 25 | 26 | /** 27 | * The attributes that should be cast. 28 | * 29 | * @var array 30 | */ 31 | protected $casts = [ 32 | 'log' => 'array', 33 | 'ran_on' => 'datetime', 34 | 'execution_time_ms' => 'integer', 35 | 'memory_used_mb' => 'float', 36 | 'status' => 'string', 37 | ]; 38 | 39 | /** 40 | * Get the table associated with the model. 41 | * 42 | * @return string 43 | */ 44 | public function getTable() 45 | { 46 | return config('laravel-patches.table_name'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/PatchExecuted.php: -------------------------------------------------------------------------------- 1 | patch = $patch; 43 | $this->batch = $batch; 44 | $this->log = $log; 45 | $this->executionTime = $executionTime; 46 | $this->memoryUsed = $memoryUsed; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Anthony Rappa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | weight: 7 4 | --- 5 | 6 | ## Making Patches 7 | 8 | ```bash 9 | php artisan make:patch patch_1_0_0 10 | ``` 11 | 12 | This created a timestamped patch file under database/patches. 13 | 14 | ## Running Patches 15 | 16 | To run all available patches: 17 | 18 | ```bash 19 | php artisan patch 20 | ``` 21 | 22 | To run each available patch in its own batch: 23 | 24 | ```bash 25 | php artisan patch --step 26 | ``` 27 | 28 | To force the patches to run in production (deploy scripts, etc.): 29 | 30 | ```bash 31 | php artisan patch --force 32 | ``` 33 | 34 | ## Rolling Back Patches 35 | 36 | To rollback all patches of the last batch: 37 | 38 | ```bash 39 | php artisan patch:rollback 40 | ``` 41 | 42 | To rollback the last X patches regardless of batch: 43 | 44 | ```bash 45 | php artisan patch:rollback --step=X 46 | ``` 47 | 48 | ## Patch File Helpers 49 | 50 | You may use the following helper commands from your patch files: 51 | 52 | Log a line to the patches log column (up method only): 53 | 54 | ```php 55 | $this->log('10 users modified'); 56 | ``` 57 | 58 | Call an Artisan command with options: 59 | 60 | ```php 61 | $this->call($command, $parameters); 62 | ``` 63 | 64 | Call a seeder by class name: 65 | 66 | ```php 67 | $this->seed($class); 68 | ``` 69 | 70 | Truncate a table by name: 71 | 72 | ```php 73 | $this->truncate($table); 74 | ``` 75 | *Note: Does not disable foreign key checks.* 76 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap/*') 5 | ->notPath('storage/*') 6 | ->notPath('resources/view/mail/*') 7 | ->in([ 8 | __DIR__ . '/config', 9 | __DIR__ . '/src', 10 | __DIR__ . '/tests', 11 | ]) 12 | ->name('*.php') 13 | ->notName('*.blade.php') 14 | ->ignoreDotFiles(true) 15 | ->ignoreVCS(true); 16 | 17 | $config = new PhpCsFixer\Config(); 18 | return $config->setRules([ 19 | '@PSR2' => true, 20 | 'array_syntax' => ['syntax' => 'short'], 21 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 22 | 'no_unused_imports' => true, 23 | 'not_operator_with_successor_space' => true, 24 | 'trailing_comma_in_multiline' => true, 25 | 'phpdoc_scalar' => true, 26 | 'unary_operator_spaces' => true, 27 | 'binary_operator_spaces' => true, 28 | 'blank_line_before_statement' => [ 29 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 30 | ], 31 | 'phpdoc_single_line_var_spacing' => true, 32 | 'phpdoc_var_without_name' => true, 33 | 'class_attributes_separation' => [ 34 | 'elements' => [ 35 | 'method' => 'one', 36 | ], 37 | ], 38 | 'method_argument_space' => [ 39 | 'on_multiline' => 'ensure_fully_multiline', 40 | 'keep_multiple_spaces_after_comma' => true, 41 | ], 42 | 'single_trait_insert_per_statement' => false, 43 | ]) 44 | ->setFinder($finder); 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-patches` will be documented in this file. 4 | 5 | ## 4.0.0 - 2025-12-05 6 | 7 | ### Added 8 | - Laravel 11 & 12 support 9 | - PHP 8.2+ support 10 | - Comprehensive test suite with Pest PHP 11 | - Repository tests (11 test cases) 12 | - Patcher tests (12 test cases) 13 | - Patch base class tests (6 test cases) 14 | - Model tests (9 test cases) 15 | - Integration tests (7 test cases) 16 | - Enhanced command tests with additional coverage 17 | 18 | ### Changed 19 | - **BREAKING**: Minimum PHP version is now 8.2 20 | - **BREAKING**: Minimum Laravel version is now 11.0 (supports 11.x - 12.x) 21 | - Updated all dependencies for Laravel 11 compatibility 22 | - Migrated from PHPUnit to Pest for testing 23 | - Updated Model to use modern Laravel 11 conventions 24 | - Removed deprecated `$dates` property from Model 25 | - Updated `$casts` to include datetime casting for `ran_on` 26 | - Changed from `$fillable` to `$guarded = []` for mass assignment 27 | - Updated README with requirements section 28 | 29 | ### Fixed 30 | - Fixed deprecated Model property usage for Laravel 11 31 | - Model now properly casts `ran_on` as datetime 32 | 33 | ## 3.0.0 - 2023-04-03 34 | 35 | ### Added 36 | - Laravel 10 Support 37 | 38 | ## 2.0.1 - 2022-02-26 39 | 40 | ### Changed 41 | 42 | - Fix table name in model - https://github.com/rappasoft/laravel-patches/pull/4 43 | 44 | ## 2.0.0 - 2022-02-21 45 | 46 | ### Added 47 | 48 | - Laravel 9 Support 49 | - Ability to specify table name 50 | 51 | ## 1.0.0 - 2021-03-07 52 | 53 | - Initial Release 54 | -------------------------------------------------------------------------------- /database/migrations/add_metadata_to_patches_table.php.stub: -------------------------------------------------------------------------------- 1 | integer('execution_time_ms')->nullable()->after('log'); 16 | $table->decimal('memory_used_mb', 8, 2)->nullable()->after('execution_time_ms'); 17 | $table->string('executed_by')->nullable()->after('memory_used_mb'); 18 | $table->string('environment', 50)->nullable()->after('executed_by'); 19 | $table->enum('status', ['success', 'failed', 'rolled_back'])->default('success')->after('environment'); 20 | $table->text('error_message')->nullable()->after('status'); 21 | $table->longText('error_trace')->nullable()->after('error_message'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::table(config('laravel-patches.table_name'), function (Blueprint $table) { 31 | $table->dropColumn([ 32 | 'execution_time_ms', 33 | 'memory_used_mb', 34 | 'executed_by', 35 | 'environment', 36 | 'status', 37 | 'error_message', 38 | 'error_trace', 39 | ]); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Patch.php: -------------------------------------------------------------------------------- 1 | call('db:seed', ['--class' => $class]); 53 | } 54 | 55 | /** 56 | * Truncate a table by name 57 | * 58 | * @param string $table 59 | */ 60 | public function truncate(string $table): void 61 | { 62 | DB::table($table)->truncate(); 63 | } 64 | 65 | /** 66 | * Add to the commands logged output 67 | * 68 | * @param string $line 69 | * 70 | * @return $this 71 | */ 72 | public function log(string $line): Patch 73 | { 74 | $this->log[] = $line; 75 | 76 | return $this; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | weight: 1 4 | --- 5 | 6 |
7 | Latest Version on Packagist 8 | Styling 9 | Tests 10 | Total Downloads 11 |
12 | 13 | This package generates patch files in the same fashion Laravel generates migrations. Each file is timestamped with an up and a down method and is associated with a batch. You may run or rollback patches with the commands below. 14 | 15 | ## Features 16 | 17 | - **Migration-style patches** with `up()` and `down()` methods 18 | - **Batch tracking** for organized rollbacks 19 | - **Comprehensive metadata** tracking (execution time, memory, user, environment) 20 | - **Event system** for monitoring and custom workflows 21 | - **Transaction support** for atomic operations 22 | - **Error handling** with detailed logging and recovery options 23 | - **Multiple commands** for managing patches (`status`, `list`, `dry-run`) 24 | - **Laravel 11 & 12 compatible** 25 | 26 | ## Requirements 27 | 28 | - PHP 8.2+ 29 | - Laravel 11.x - 12.x 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rappasoft/laravel-patches", 3 | "description": "Run patches migration style in your Laravel applications.", 4 | "keywords": [ 5 | "rappasoft", 6 | "laravel-patches" 7 | ], 8 | "homepage": "https://github.com/rappasoft/laravel-patches", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Anthony Rappa", 13 | "email": "rappa819@gmail.com", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.2", 19 | "spatie/laravel-package-tools": "^1.16", 20 | "illuminate/contracts": "^11.0|^12.0" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.64", 24 | "orchestra/testbench": "^9.0|^10.0", 25 | "pestphp/pest": "^2.0|^3.0", 26 | "pestphp/pest-plugin-laravel": "^3.0", 27 | "phpunit/phpunit": "^10.5|^11.5", 28 | "spatie/laravel-ray": "^1.37" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Rappasoft\\LaravelPatches\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Rappasoft\\LaravelPatches\\Tests\\": "tests" 38 | } 39 | }, 40 | "scripts": { 41 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes", 42 | "test": "vendor/bin/pest --colors=always", 43 | "test-coverage": "vendor/bin/pest --coverage --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true, 47 | "allow-plugins": { 48 | "pestphp/pest-plugin": true 49 | } 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "Rappasoft\\LaravelPatches\\LaravelPatchesServiceProvider" 55 | ] 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | weight: 6 4 | --- 5 | 6 | You can publish the config file with: 7 | 8 | ```bash 9 | php artisan vendor:publish --provider="Rappasoft\LaravelPatches\LaravelPatchesServiceProvider" --tag="laravel-patches-config" 10 | ``` 11 | 12 | You can publish and run the migrations with: 13 | 14 | ```bash 15 | php artisan vendor:publish --provider="Rappasoft\LaravelPatches\LaravelPatchesServiceProvider" --tag="laravel-patches-migrations" 16 | php artisan migrate 17 | ``` 18 | 19 | ## Configuration Options 20 | 21 | The configuration file `config/laravel-patches.php` includes the following options: 22 | 23 | ```php 24 | return [ 25 | /** 26 | * Table name for storing patch information 27 | */ 28 | 'table_name' => env('PATCHES_TABLE_NAME', 'patches'), 29 | 30 | /** 31 | * Transaction settings 32 | * Wrap patches in database transactions for automatic rollback on errors 33 | */ 34 | 'use_transactions' => env('PATCHES_USE_TRANSACTIONS', false), 35 | 36 | /** 37 | * Error handling 38 | * Stop execution on first error, or continue running remaining patches 39 | */ 40 | 'stop_on_error' => env('PATCHES_STOP_ON_ERROR', true), 41 | 'log_errors' => env('PATCHES_LOG_ERRORS', true), 42 | 43 | /** 44 | * Metadata tracking 45 | * Track execution metrics like time, memory, user, and environment 46 | */ 47 | 'track_metadata' => env('PATCHES_TRACK_METADATA', true), 48 | 'track_memory' => env('PATCHES_TRACK_MEMORY', true), 49 | 'track_user' => env('PATCHES_TRACK_USER', true), 50 | 51 | /** 52 | * Display settings 53 | * Show patch descriptions in commands 54 | */ 55 | 'show_descriptions' => env('PATCHES_SHOW_DESCRIPTIONS', true), 56 | ]; 57 | ``` 58 | 59 | ## Environment Variables 60 | 61 | You can control these settings via your `.env` file: 62 | 63 | ```bash 64 | # Table Configuration 65 | PATCHES_TABLE_NAME=patches 66 | 67 | # Transaction Settings 68 | PATCHES_USE_TRANSACTIONS=false 69 | 70 | # Error Handling 71 | PATCHES_STOP_ON_ERROR=true 72 | PATCHES_LOG_ERRORS=true 73 | 74 | # Metadata Tracking 75 | PATCHES_TRACK_METADATA=true 76 | PATCHES_TRACK_MEMORY=true 77 | PATCHES_TRACK_USER=true 78 | 79 | # Display 80 | PATCHES_SHOW_DESCRIPTIONS=true 81 | ``` 82 | 83 | ## Customizing Table Name 84 | 85 | If you need to use a custom table name: 86 | 87 | ```php 88 | // config/laravel-patches.php 89 | 'table_name' => 'custom_patches_table', 90 | ``` 91 | 92 | Then republish and run migrations to create the table with your custom name. 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Package Logo](https://banners.beyondco.de/Laravel%20Patches.png?theme=light&packageManager=composer+require&packageName=rappasoft%2Flaravel-patches&pattern=architect&style=style_1&description=Run+patches+migration+style+in+your+Laravel+applications.&md=1&showWatermark=0&fontSize=100px&images=puzzle) 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/rappasoft/laravel-patches.svg?style=flat-square)](https://packagist.org/packages/rappasoft/laravel-patches) 4 | [![Styling](https://github.com/rappasoft/laravel-patches/actions/workflows/php-cs-fixer.yml/badge.svg)](https://github.com/rappasoft/laravel-patches/actions/workflows/php-cs-fixer.yml) 5 | [![Tests](https://github.com/rappasoft/laravel-patches/actions/workflows/run-tests.yml/badge.svg)](https://github.com/rappasoft/laravel-patches/actions/workflows/run-tests.yml) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/rappasoft/laravel-patches.svg?style=flat-square)](https://packagist.org/packages/rappasoft/laravel-patches) 7 | 8 | ### Enjoying this package? [Buy me a beer 🍺](https://www.buymeacoffee.com/rappasoft) 9 | 10 | This package generates patch files in the same fashion Laravel generates migrations. Each file is timestamped with an up and a down method and is associated with a batch. You may run or rollback patches with the commands below. 11 | 12 | This is a very simple package. It runs whatever is in your up and down methods on each patch in the order the patches are defined. It currently does not handle any errors or database transactions, please make sure you account for everything and have a backup plan when running patches in production. 13 | 14 | ## Requirements 15 | 16 | - PHP 8.2+ 17 | - Laravel 11.x - 12.x 18 | 19 | ## Installation 20 | 21 | You can install the package via composer: 22 | 23 | ```bash 24 | composer require rappasoft/laravel-patches 25 | ``` 26 | 27 | Publish and run the migrations: 28 | 29 | ```bash 30 | php artisan vendor:publish --provider="Rappasoft\LaravelPatches\LaravelPatchesServiceProvider" --tag="laravel-patches-migrations" 31 | php artisan migrate 32 | ``` 33 | 34 | ## Documentation and Usage Instructions 35 | 36 | See the [documentation](https://rappasoft.com/docs/laravel-patches) for detailed installation and usage instructions. 37 | 38 | ## Testing 39 | 40 | ```bash 41 | composer test 42 | ``` 43 | 44 | ## Changelog 45 | 46 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 47 | 48 | ## Contributing 49 | 50 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 51 | 52 | ## Security Vulnerabilities 53 | 54 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 55 | 56 | ## Credits 57 | 58 | - [Anthony Rappa](https://github.com/rappasoft) 59 | - [All Contributors](../../contributors) 60 | 61 | ## License 62 | 63 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 64 | -------------------------------------------------------------------------------- /src/Commands/StatusCommand.php: -------------------------------------------------------------------------------- 1 | patcher = $patcher; 51 | $this->repository = $repository; 52 | } 53 | 54 | /** 55 | * Execute the console command. 56 | */ 57 | public function handle(): int 58 | { 59 | if (! $this->patcher->patchesTableExists()) { 60 | $this->error(__('The patches table does not exist, did you forget to migrate?')); 61 | 62 | return 1; 63 | } 64 | 65 | $files = $this->patcher->getPatchFiles($this->patcher->getPatchPaths()); 66 | $ran = collect($this->repository->getRan()); 67 | 68 | $headers = ['Status', 'Patch', 'Batch', 'Ran On', 'Time (ms)', 'Status']; 69 | $rows = []; 70 | 71 | foreach ($files as $name => $file) { 72 | $patchRecord = Patch::where('patch', $name)->first(); 73 | 74 | if ($patchRecord) { 75 | // Filter by options 76 | if ($this->option('pending')) { 77 | continue; 78 | } 79 | 80 | if ($this->option('batch') && $patchRecord->batch != $this->option('batch')) { 81 | continue; 82 | } 83 | 84 | $status = $patchRecord->status === 'success' ? '✓' : '✗'; 85 | $statusText = $patchRecord->status === 'success' ? 'Success' : 'Failed'; 86 | 87 | $rows[] = [ 88 | $status, 89 | $name, 90 | $patchRecord->batch, 91 | $patchRecord->ran_on ? $patchRecord->ran_on->format('Y-m-d H:i:s') : '-', 92 | $patchRecord->execution_time_ms ?? '-', 93 | $statusText, 94 | ]; 95 | } else { 96 | // Pending patch 97 | if ($this->option('ran')) { 98 | continue; 99 | } 100 | 101 | $rows[] = [ 102 | '⋯', 103 | $name, 104 | 'Pending', 105 | '-', 106 | '-', 107 | 'Pending', 108 | ]; 109 | } 110 | } 111 | 112 | if (empty($rows)) { 113 | $this->info('No patches found matching the criteria.'); 114 | 115 | return 0; 116 | } 117 | 118 | $this->table($headers, $rows); 119 | 120 | // Summary 121 | $totalRan = $ran->count(); 122 | $totalPending = count($files) - $totalRan; 123 | 124 | $this->newLine(); 125 | $this->line("Ran: {$totalRan}"); 126 | $this->line("Pending: {$totalPending}"); 127 | $this->line("Total: " . count($files)); 128 | 129 | return 0; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Commands/ListCommand.php: -------------------------------------------------------------------------------- 1 | patcher = $patcher; 51 | $this->repository = $repository; 52 | } 53 | 54 | /** 55 | * Execute the console command. 56 | */ 57 | public function handle(): int 58 | { 59 | if (! $this->patcher->patchesTableExists()) { 60 | $this->error(__('The patches table does not exist, did you forget to migrate?')); 61 | 62 | return 1; 63 | } 64 | 65 | $files = $this->patcher->getPatchFiles($this->patcher->getPatchPaths()); 66 | $patches = []; 67 | 68 | foreach ($files as $name => $file) { 69 | $patchRecord = Patch::where('patch', $name)->first(); 70 | 71 | $patchInfo = [ 72 | 'name' => $name, 73 | 'status' => $patchRecord ? $patchRecord->status : 'pending', 74 | 'batch' => $patchRecord?->batch, 75 | 'ran_on' => $patchRecord?->ran_on?->toDateTimeString(), 76 | 'execution_time_ms' => $patchRecord?->execution_time_ms, 77 | ]; 78 | 79 | // Apply filters 80 | if ($this->option('status') && $patchInfo['status'] !== $this->option('status')) { 81 | continue; 82 | } 83 | 84 | if ($this->option('batch') && $patchInfo['batch'] != $this->option('batch')) { 85 | continue; 86 | } 87 | 88 | $patches[] = $patchInfo; 89 | } 90 | 91 | if (empty($patches)) { 92 | if ($this->option('json')) { 93 | $this->line(json_encode([], JSON_PRETTY_PRINT)); 94 | } else { 95 | $this->info('No patches found matching the criteria.'); 96 | } 97 | 98 | return 0; 99 | } 100 | 101 | // Output as JSON 102 | if ($this->option('json')) { 103 | $this->line(json_encode($patches, JSON_PRETTY_PRINT)); 104 | 105 | return 0; 106 | } 107 | 108 | // Output as table 109 | $headers = ['Patch', 'Status', 'Batch', 'Ran On', 'Time (ms)']; 110 | $rows = []; 111 | 112 | foreach ($patches as $patch) { 113 | $status = match ($patch['status']) { 114 | 'success' => 'Success', 115 | 'failed' => 'Failed', 116 | default => 'Pending', 117 | }; 118 | 119 | $rows[] = [ 120 | $patch['name'], 121 | $status, 122 | $patch['batch'] ?? '-', 123 | $patch['ran_on'] ?? '-', 124 | $patch['execution_time_ms'] ?? '-', 125 | ]; 126 | } 127 | 128 | $this->table($headers, $rows); 129 | 130 | $this->newLine(); 131 | $this->info('Total patches: ' . count($patches)); 132 | 133 | return 0; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /docs/metadata.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Metadata & Advanced Features 3 | weight: 11 4 | --- 5 | 6 | Laravel Patches automatically tracks comprehensive metadata about patch execution, providing valuable insights for monitoring and debugging. 7 | 8 | ## Tracked Metadata 9 | 10 | Every patch execution automatically captures: 11 | 12 | - **execution_time_ms** - Duration in milliseconds 13 | - **memory_used_mb** - Peak memory usage 14 | - **executed_by** - User or process that ran the patch 15 | - **environment** - Application environment (production, staging, local) 16 | - **status** - Execution status (success, failed, rolled_back) 17 | - **error_message** - Exception message if failed 18 | - **error_trace** - Full stack trace if failed 19 | - **log** - Custom log messages from `$this->log()` 20 | - **ran_on** - Timestamp of execution 21 | 22 | ## Configuration 23 | 24 | Control metadata tracking in `config/laravel-patches.php`: 25 | 26 | ```php 27 | return [ 28 | 'track_metadata' => env('PATCHES_TRACK_METADATA', true), 29 | 'track_memory' => env('PATCHES_TRACK_MEMORY', true), 30 | 'track_user' => env('PATCHES_TRACK_USER', true), 31 | ]; 32 | ``` 33 | 34 | ## Viewing Metadata 35 | 36 | ### Via Commands 37 | 38 | ```bash 39 | # See all metadata in table view 40 | php artisan patch:status 41 | 42 | # Get JSON output for processing 43 | php artisan patch:list --json 44 | ``` 45 | 46 | ### Via Database 47 | 48 | ```php 49 | use Rappasoft\LaravelPatches\Models\Patch; 50 | 51 | $patches = Patch::where('status', 'success') 52 | ->orderBy('execution_time_ms', 'desc') 53 | ->get(); 54 | 55 | foreach ($patches as $patch) { 56 | echo "{$patch->patch}: {$patch->execution_time_ms}ms\n"; 57 | } 58 | ``` 59 | 60 | ### Via Events 61 | 62 | ```php 63 | use Rappasoft\LaravelPatches\Events\PatchExecuted; 64 | 65 | Event::listen(PatchExecuted::class, function ($event) { 66 | Log::info('Patch Metrics', [ 67 | 'patch' => $event->patch, 68 | 'time_ms' => $event->executionTime, 69 | 'memory_mb' => $event->memoryUsed, 70 | ]); 71 | }); 72 | ``` 73 | 74 | ## Patch Descriptions 75 | 76 | Add descriptions to your patches for better documentation: 77 | 78 | ```php 79 | use Rappasoft\LaravelPatches\Patch; 80 | 81 | class UpdateUserEmails extends Patch 82 | { 83 | public function description(): ?string 84 | { 85 | return 'Updates all user emails from old domain to new domain'; 86 | } 87 | 88 | public function up() 89 | { 90 | // Patch logic 91 | } 92 | 93 | public function down() 94 | { 95 | // Rollback logic 96 | } 97 | } 98 | ``` 99 | 100 | Descriptions appear in: 101 | - `patch:status` command output 102 | - Event payloads 103 | - Error messages 104 | 105 | ## Performance Analysis 106 | 107 | ### Find Slow Patches 108 | 109 | ```php 110 | $slowPatches = Patch::where('execution_time_ms', '>', 5000) 111 | ->orderByDesc('execution_time_ms') 112 | ->get(); 113 | ``` 114 | 115 | ### Memory-Heavy Patches 116 | 117 | ```php 118 | $memoryHeavy = Patch::where('memory_used_mb', '>', 100) 119 | ->get(); 120 | ``` 121 | 122 | ### Failure Rate 123 | 124 | ```php 125 | $totalPatches = Patch::count(); 126 | $failedPatches = Patch::where('status', 'failed')->count(); 127 | $failureRate = ($failedPatches / $totalPatches) * 100; 128 | ``` 129 | 130 | ## Monitoring Integration 131 | 132 | ### Send Metrics to External Service 133 | 134 | ```php 135 | Event::listen(PatchExecuted::class, function ($event) { 136 | Metrics::timing('patch.execution', $event->executionTime, [ 137 | 'patch' => $event->patch, 138 | 'environment' => app()->environment(), 139 | ]); 140 | 141 | Metrics::gauge('patch.memory', $event->memoryUsed); 142 | }); 143 | ``` 144 | 145 | ### Alert on Anomalies 146 | 147 | ```php 148 | Event::listen(PatchExecuted::class, function ($event) { 149 | $avgTime = Patch::where('patch', $event->patch) 150 | ->avg('execution_time_ms'); 151 | 152 | if ($event->executionTime > ($avgTime * 2)) { 153 | Slack::send("⚠️ Slow patch detected: {$event->patch}"); 154 | } 155 | }); 156 | ``` 157 | 158 | ## Best Practices 159 | 160 | 1. **Review metadata regularly** to identify performance issues 161 | 2. **Add descriptions** to all production patches 162 | 3. **Monitor execution times** for degradation 163 | 4. **Set up alerts** for failed patches 164 | 5. **Keep metadata enabled** in production for troubleshooting 165 | -------------------------------------------------------------------------------- /src/Repository.php: -------------------------------------------------------------------------------- 1 | =', '1'); 24 | 25 | return $query->orderBy('batch', 'desc') 26 | ->orderBy('patch', 'desc') 27 | ->take($steps) 28 | ->get() 29 | ->all(); 30 | } 31 | 32 | /** 33 | * Get the list of patches already ran 34 | * 35 | * @return array 36 | */ 37 | public function getRan(): array 38 | { 39 | return Patch::orderBy('batch') 40 | ->orderBy('patch') 41 | ->pluck('patch') 42 | ->all(); 43 | } 44 | 45 | /** 46 | * Get the last patches batch. 47 | * 48 | * @return array 49 | */ 50 | public function getLast(): array 51 | { 52 | $query = Patch::where('batch', $this->getLastBatchNumber()); 53 | 54 | return $query->orderBy('patch', 'desc') 55 | ->get() 56 | ->all(); 57 | } 58 | 59 | /** 60 | * Log that a patch was run. 61 | * 62 | * @param string $file 63 | * @param int $batch 64 | * @param array $log 65 | * @param int|null $executionTime 66 | * @param float|null $memoryUsed 67 | * @param string|null $executedBy 68 | * @param string|null $environment 69 | * @param string $status 70 | * @param string|null $errorMessage 71 | * @param string|null $errorTrace 72 | * 73 | * @return void 74 | */ 75 | public function log( 76 | string $file, 77 | int $batch, 78 | array $log = [], 79 | ?int $executionTime = null, 80 | ?float $memoryUsed = null, 81 | ?string $executedBy = null, 82 | ?string $environment = null, 83 | string $status = 'success', 84 | ?string $errorMessage = null, 85 | ?string $errorTrace = null 86 | ): void { 87 | $data = [ 88 | 'patch' => $file, 89 | 'batch' => $batch, 90 | 'log' => $log, 91 | 'ran_on' => now(), 92 | 'status' => $status, 93 | ]; 94 | 95 | if (config('laravel-patches.track_metadata', true)) { 96 | $data['execution_time_ms'] = $executionTime; 97 | $data['environment'] = $environment ?? app()->environment(); 98 | } 99 | 100 | if (config('laravel-patches.track_memory', true)) { 101 | $data['memory_used_mb'] = $memoryUsed; 102 | } 103 | 104 | if (config('laravel-patches.track_user', true)) { 105 | $data['executed_by'] = $executedBy ?? $this->getCurrentUser(); 106 | } 107 | 108 | if (config('laravel-patches.log_errors', true) && $status === 'failed') { 109 | $data['error_message'] = $errorMessage; 110 | $data['error_trace'] = $errorTrace; 111 | } 112 | 113 | Patch::create($data); 114 | } 115 | 116 | /** 117 | * Get the current user executing the patch. 118 | * 119 | * @return string 120 | */ 121 | protected function getCurrentUser(): string 122 | { 123 | if (app()->runningInConsole()) { 124 | return get_current_user() . '@' . gethostname(); 125 | } 126 | 127 | return auth()->check() ? auth()->user()->email ?? auth()->id() : 'guest'; 128 | } 129 | 130 | /** 131 | * Delete a patch from the database 132 | * 133 | * @param object $patch 134 | */ 135 | public function delete(object $patch): void 136 | { 137 | Patch::where('patch', $patch->patch)->delete(); 138 | } 139 | 140 | /** 141 | * Get the next patch batch number to use 142 | * 143 | * @return int 144 | */ 145 | public function getNextBatchNumber(): int 146 | { 147 | return $this->getLastBatchNumber() + 1; 148 | } 149 | 150 | /** 151 | * Get the last patch batch number. 152 | * 153 | * @return int 154 | */ 155 | public function getLastBatchNumber(): ?int 156 | { 157 | return Patch::max('batch'); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Commands/RollbackCommand.php: -------------------------------------------------------------------------------- 1 | patcher = $patcher; 60 | $this->repository = $repository; 61 | } 62 | 63 | /** 64 | * Execute the console command. 65 | * 66 | * @return int 67 | * @throws FileNotFoundException 68 | */ 69 | public function handle(): int 70 | { 71 | if (! $this->confirmToProceed()) { 72 | return 1; 73 | } 74 | 75 | $this->rollback(); 76 | 77 | return 0; 78 | } 79 | 80 | /** 81 | * Rollback the appropriate patches 82 | * 83 | * @return array 84 | * @throws FileNotFoundException 85 | */ 86 | protected function rollback(): array 87 | { 88 | $patches = $this->getPatchesForRollback(); 89 | 90 | if (! count($patches)) { 91 | $this->info('Nothing to rollback.'); 92 | 93 | return []; 94 | } 95 | 96 | return $this->rollbackPatches($patches); 97 | } 98 | 99 | /** 100 | * Decide which patch files to rollback and run their down methods 101 | * 102 | * @param $patches 103 | * 104 | * @return array 105 | * @throws FileNotFoundException 106 | */ 107 | protected function rollbackPatches($patches): array 108 | { 109 | $rolledBack = []; 110 | 111 | $this->patcher->requireFiles($files = $this->patcher->getPatchFiles($this->patcher->getPatchPaths())); 112 | 113 | foreach ($patches as $patch) { 114 | $patch = (object) $patch; 115 | 116 | if (! $file = Arr::get($files, $patch->patch)) { 117 | $this->line("Patch not found: {$patch->patch}"); 118 | 119 | continue; 120 | } 121 | 122 | $rolledBack[] = $file; 123 | 124 | $this->runDown($file, $patch); 125 | } 126 | 127 | return $rolledBack; 128 | } 129 | 130 | /** 131 | * Decide which patch files to choose for rollback based on passed in options 132 | * 133 | * @return array 134 | */ 135 | protected function getPatchesForRollback(): array 136 | { 137 | $steps = (int)$this->option('step'); 138 | 139 | return $steps > 0 ? $this->repository->getPatches($steps) : $this->repository->getLast(); 140 | } 141 | 142 | /** 143 | * Run the down method on the patch 144 | * 145 | * @param $file 146 | * @param object $patch 147 | */ 148 | protected function runDown($file, object $patch): void 149 | { 150 | $instance = $this->patcher->resolve($name = $this->patcher->getPatchName($file)); 151 | 152 | $this->line("Rolling back: {$name}"); 153 | 154 | $result = $this->patcher->runPatch($instance, 'down', $name); 155 | 156 | $runTime = number_format($result['executionTime'], 2); 157 | 158 | $this->repository->delete($patch); 159 | 160 | $this->line("Rolled back: {$name} ({$runTime}ms)"); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Commands 3 | weight: 3 4 | --- 5 | 6 | Laravel Patches provides several commands to manage your patches. 7 | 8 | ## Creating Patches 9 | 10 | ### make:patch 11 | 12 | Create a new patch file: 13 | 14 | ```bash 15 | php artisan make:patch patch_name 16 | ``` 17 | 18 | This creates a timestamped patch file in `database/patches`. 19 | 20 | --- 21 | 22 | ## Running Patches 23 | 24 | ### patch 25 | 26 | Run all pending patches: 27 | 28 | ```bash 29 | php artisan patch 30 | ``` 31 | 32 | **Options:** 33 | 34 | - `--force` - Force the operation to run in production 35 | - `--step` - Run each patch in its own batch (allows individual rollback) 36 | - `--dry-run` - Preview patches without executing them 37 | 38 | **Examples:** 39 | 40 | ```bash 41 | # Run in production 42 | php artisan patch --force 43 | 44 | # Run each patch in separate batches 45 | php artisan patch --step 46 | 47 | # Preview what would run 48 | php artisan patch --dry-run 49 | ``` 50 | 51 | **Dry Run Mode:** 52 | 53 | The `--dry-run` flag lets you safely preview which patches would execute without making any database changes: 54 | 55 | ```bash 56 | php artisan patch --dry-run 57 | ``` 58 | 59 | Output example: 60 | ``` 61 | Dry run mode - No patches will be executed 62 | 63 | Would run: 2024_01_01_000000_fix_user_data 64 | Would run: 2024_01_02_000000_update_settings 65 | 66 | Total patches: 2 67 | ``` 68 | 69 | --- 70 | 71 | ## Rolling Back Patches 72 | 73 | ### patch:rollback 74 | 75 | Rollback the last batch of patches: 76 | 77 | ```bash 78 | php artisan patch:rollback 79 | ``` 80 | 81 | **Options:** 82 | 83 | - `--step=X` - Rollback X number of patches 84 | 85 | **Examples:** 86 | 87 | ```bash 88 | # Rollback last batch 89 | php artisan patch:rollback 90 | 91 | # Rollback last 3 patches 92 | php artisan patch:rollback --step=3 93 | ``` 94 | 95 | --- 96 | 97 | ## Viewing Patch Status 98 | 99 | ### patch:status 100 | 101 | Display comprehensive status of all patches: 102 | 103 | ```bash 104 | php artisan patch:status 105 | ``` 106 | 107 | **Options:** 108 | 109 | - `--pending` - Show only pending patches 110 | - `--ran` - Show only executed patches 111 | - `--batch=N` - Filter by batch number 112 | 113 | **Output Example:** 114 | 115 | ``` 116 | ┌────────┬──────────────────────────────┬───────┬─────────────────────┬───────────┬─────────┐ 117 | │ Status │ Patch │ Batch │ Ran On │ Time (ms) │ Status │ 118 | ├────────┼──────────────────────────────┼───────┼─────────────────────┼───────────┼─────────┤ 119 | │ ✓ │ 2024_01_01_fix_users │ 1 │ 2024-01-15 10:30:00 │ 145 │ Success │ 120 | │ ✓ │ 2024_01_02_update_settings │ 1 │ 2024-01-15 10:30:01 │ 89 │ Success │ 121 | │ ⋯ │ 2024_01_03_cleanup_data │ Pending│ - │ - │ Pending │ 122 | └────────┴──────────────────────────────┴───────┴─────────────────────┴───────────┴─────────┘ 123 | 124 | Ran: 2 125 | Pending: 1 126 | Total: 3 127 | ``` 128 | 129 | **Examples:** 130 | 131 | ```bash 132 | # Show only pending patches 133 | php artisan patch:status --pending 134 | 135 | # Show only executed patches 136 | php artisan patch:status --ran 137 | 138 | # Show patches from batch 2 139 | php artisan patch:status --batch=2 140 | ``` 141 | 142 | --- 143 | 144 | ## Listing Patches 145 | 146 | ### patch:list 147 | 148 | List all available patches with optional filtering: 149 | 150 | ```bash 151 | php artisan patch:list 152 | ``` 153 | 154 | **Options:** 155 | 156 | - `--status=STATUS` - Filter by status (`pending`, `ran`, `failed`) 157 | - `--batch=N` - Filter by batch number 158 | - `--json` - Output as JSON 159 | 160 | **Examples:** 161 | 162 | ```bash 163 | # List all patches 164 | php artisan patch:list 165 | 166 | # List only pending patches 167 | php artisan patch:list --status=pending 168 | 169 | # List patches from batch 1 170 | php artisan patch:list --batch=1 171 | 172 | # Output as JSON 173 | php artisan patch:list --json 174 | ``` 175 | 176 | **JSON Output Example:** 177 | 178 | ```json 179 | [ 180 | { 181 | "name": "2024_01_01_fix_users", 182 | "status": "success", 183 | "batch": 1, 184 | "ran_on": "2024-01-15 10:30:00", 185 | "execution_time_ms": 145 186 | }, 187 | { 188 | "name": "2024_01_02_update_settings", 189 | "status": "pending", 190 | "batch": null, 191 | "ran_on": null, 192 | "execution_time_ms": null 193 | } 194 | ] 195 | ``` 196 | 197 | --- 198 | 199 | ## Command Summary 200 | 201 | | Command | Description | 202 | |---------|-------------| 203 | | `make:patch` | Create a new patch file | 204 | | `patch` | Run pending patches | 205 | | `patch:rollback` | Rollback patches | 206 | | `patch:status` | View patch status | 207 | | `patch:list` | List all patches | 208 | 209 | ## Best Practices 210 | 211 | 1. **Always use `--dry-run` first** in production to preview changes 212 | 2. **Use `--step` mode** when testing new patches for easier rollback 213 | 3. **Check status regularly** with `patch:status` to monitor your patches 214 | 4. **Use descriptive names** when creating patches 215 | 5. **Test patches** in staging before running in production 216 | -------------------------------------------------------------------------------- /src/Commands/PatchMakeCommand.php: -------------------------------------------------------------------------------- 1 | patcher = $patcher; 67 | $this->composer = $composer; 68 | $this->files = $files; 69 | } 70 | 71 | /** 72 | * Execute the console command. 73 | * 74 | * @return int 75 | * @throws FileNotFoundException 76 | */ 77 | public function handle(): int 78 | { 79 | $this->writePatch(Str::snake(trim($this->argument('name')))); 80 | 81 | $this->composer->dumpAutoloads(); 82 | 83 | return 0; 84 | } 85 | 86 | /** 87 | * Create the patch file 88 | * 89 | * @param string $name 90 | * 91 | * @throws FileNotFoundException 92 | */ 93 | protected function writePatch(string $name): void 94 | { 95 | $file = pathinfo($this->create($name, $this->patcher->getPatchPath()), PATHINFO_FILENAME); 96 | 97 | $this->line("Created Patch: {$file}"); 98 | } 99 | 100 | /** 101 | * Create the patch and return the path 102 | * 103 | * @param $name 104 | * @param $path 105 | * 106 | * @return string 107 | * @throws FileNotFoundException 108 | */ 109 | protected function create($name, $path): string 110 | { 111 | $this->ensurePatchDoesntAlreadyExist($name); 112 | 113 | $stub = $this->getStub(); 114 | 115 | $path = $this->getPath($name, $path); 116 | 117 | $this->files->ensureDirectoryExists(dirname($path)); 118 | 119 | $this->files->put($path, $this->populateStub($name, $stub)); 120 | 121 | return $path; 122 | } 123 | 124 | /** 125 | * Make sure two patches with the same class name do not get created 126 | * 127 | * @param $name 128 | * 129 | * @throws FileNotFoundException 130 | */ 131 | protected function ensurePatchDoesntAlreadyExist($name): void 132 | { 133 | $patchesPath = $this->patcher->getPatchPath(); 134 | 135 | if (! empty($patchesPath)) { 136 | $patchFiles = $this->files->glob($patchesPath.'/*.php'); 137 | 138 | foreach ($patchFiles as $patchFile) { 139 | $this->files->requireOnce($patchFile); 140 | } 141 | } 142 | 143 | if (class_exists($className = $this->patcher->getClassName($name))) { 144 | throw new InvalidArgumentException("A {$className} patch class already exists."); 145 | } 146 | } 147 | 148 | /** 149 | * Get the patch stub contents 150 | * 151 | * @return string 152 | * @throws FileNotFoundException 153 | */ 154 | protected function getStub(): string 155 | { 156 | return $this->files->get($this->stubPath().'/patch.stub'); 157 | } 158 | 159 | /** 160 | * Get the path to the stubs 161 | * 162 | * @return string 163 | */ 164 | protected function stubPath(): string 165 | { 166 | return __DIR__.'/stubs'; 167 | } 168 | 169 | /** 170 | * Get the path of the file to be created 171 | * 172 | * @param $name 173 | * @param $path 174 | * 175 | * @return string 176 | */ 177 | protected function getPath($name, $path): string 178 | { 179 | return $path.'/'.$this->getDatePrefix().'_'.$name.'.php'; 180 | } 181 | 182 | /** 183 | * Get the date prefix of the file 184 | * 185 | * @return false|string 186 | */ 187 | protected function getDatePrefix() 188 | { 189 | return date('Y_m_d_His'); 190 | } 191 | 192 | /** 193 | * Replace the placeholders in the stub with their actual data 194 | * 195 | * @param $name 196 | * @param $stub 197 | * 198 | * @return string|string[] 199 | */ 200 | protected function populateStub($name, $stub) 201 | { 202 | return str_replace('{{ class }}', $this->patcher->getClassName($name), $stub); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Commands/PatchCommand.php: -------------------------------------------------------------------------------- 1 | patcher = $patcher; 62 | $this->repository = $repository; 63 | } 64 | 65 | /** 66 | * Execute the console command. 67 | * 68 | * @return int 69 | * @throws FileNotFoundException 70 | */ 71 | public function handle(): int 72 | { 73 | if (! $this->confirmToProceed()) { 74 | return 1; 75 | } 76 | 77 | if (! $this->patcher->patchesTableExists()) { 78 | $this->error(__('The patches table does not exist, did you forget to migrate?')); 79 | 80 | return 1; 81 | } 82 | 83 | $files = $this->patcher->getPatchFiles($this->patcher->getPatchPaths()); 84 | 85 | $this->patcher->requireFiles($patches = $this->pendingPatches($files, $this->repository->getRan())); 86 | 87 | // Handle dry run mode 88 | if ($this->option('dry-run')) { 89 | $this->handleDryRun($patches); 90 | 91 | return 0; 92 | } 93 | 94 | $this->runPending($patches); 95 | 96 | return 0; 97 | } 98 | 99 | /** 100 | * Handle dry run mode 101 | * 102 | * @param array $patches 103 | */ 104 | protected function handleDryRun(array $patches): void 105 | { 106 | if (! count($patches)) { 107 | $this->info(__('No patches to run.')); 108 | 109 | return; 110 | } 111 | 112 | $this->line("Dry run mode - No patches will be executed"); 113 | $this->newLine(); 114 | 115 | foreach ($patches as $file) { 116 | $name = $this->patcher->getPatchName($file); 117 | $this->line("Would run: {$name}"); 118 | } 119 | 120 | $this->newLine(); 121 | $this->info("Total patches: " . count($patches)); 122 | } 123 | 124 | /** 125 | * Get the patch files that have not yet run. 126 | * 127 | * @param array $files 128 | * @param array $ran 129 | * 130 | * @return array 131 | */ 132 | protected function pendingPatches(array $files, array $ran): array 133 | { 134 | return collect($files) 135 | ->reject(fn ($file) => in_array($this->patcher->getPatchName($file), $ran, true)) 136 | ->values()->all(); 137 | } 138 | 139 | /** 140 | * Run pending patches 141 | * 142 | * @param array $patches 143 | */ 144 | protected function runPending(array $patches): void 145 | { 146 | if (! count($patches)) { 147 | $this->info(__('No patches to run.')); 148 | 149 | return; 150 | } 151 | 152 | $batch = $this->repository->getNextBatchNumber(); 153 | 154 | foreach ($patches as $file) { 155 | $this->runUp($file, $batch); 156 | 157 | if ($this->option('step')) { 158 | $batch++; 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Run the up method on the patch 165 | * 166 | * @param $file 167 | * @param int $batch 168 | */ 169 | protected function runUp($file, int $batch): void 170 | { 171 | $patch = $this->patcher->resolve($name = $this->patcher->getPatchName($file)); 172 | 173 | $this->line("Running Patch: {$name}"); 174 | 175 | $result = $this->patcher->runPatch($patch, 'up', $name, $batch); 176 | 177 | $runTime = number_format($result['executionTime'], 2); 178 | 179 | if ($result['exception']) { 180 | $this->repository->log( 181 | $name, 182 | $batch, 183 | $result['log'] ?? [], 184 | $result['executionTime'], 185 | $result['memoryUsed'], 186 | null, 187 | null, 188 | 'failed', 189 | $result['exception']->getMessage(), 190 | $result['exception']->getTraceAsString() 191 | ); 192 | 193 | $this->error("Failed: {$name} ({$runTime}ms)"); 194 | $this->error(" Error: {$result['exception']->getMessage()}"); 195 | 196 | if (config('laravel-patches.stop_on_error', true)) { 197 | throw $result['exception']; 198 | } 199 | } else { 200 | $this->repository->log( 201 | $name, 202 | $batch, 203 | $result['log'] ?? [], 204 | $result['executionTime'], 205 | $result['memoryUsed'] 206 | ); 207 | 208 | $this->line("Patched: {$name} ({$runTime}ms)"); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Patcher.php: -------------------------------------------------------------------------------- 1 | files = $files; 45 | } 46 | 47 | /** 48 | * Set the output implementation that should be used by the console. 49 | * 50 | * @param OutputInterface $output 51 | * @return $this 52 | */ 53 | public function setOutput(OutputInterface $output): Patcher 54 | { 55 | $this->output = $output; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Make sure the patches table exists 62 | * 63 | * @return bool 64 | */ 65 | public function patchesTableExists(): bool 66 | { 67 | return Schema::hasTable(config('laravel-patches.table_name')); 68 | } 69 | 70 | /** 71 | * Return the array of paths to look through for patches 72 | * 73 | * @return array 74 | */ 75 | public function getPatchPaths(): array 76 | { 77 | return [$this->getPatchPath()]; 78 | } 79 | 80 | /** 81 | * Get the path to the patch directory. 82 | * 83 | * @return string 84 | */ 85 | public function getPatchPath(): string 86 | { 87 | return database_path('patches'); 88 | } 89 | 90 | /** 91 | * @param $paths 92 | * 93 | * @return array 94 | */ 95 | public function getPatchFiles($paths): array 96 | { 97 | return collect($paths) 98 | ->flatMap(fn ($path) => Str::endsWith($path, '.php') ? [$path] : $this->files->glob($path.'/*_*.php')) 99 | ->filter() 100 | ->values() 101 | ->keyBy(fn ($file) => $this->getPatchName($file)) 102 | ->sortBy(fn ($_file, $key) => $key) 103 | ->all(); 104 | } 105 | 106 | /** 107 | * Get the ClassName 108 | * 109 | * @param $name 110 | * 111 | * @return string 112 | */ 113 | public function getClassName($name): string 114 | { 115 | return Str::studly($name); 116 | } 117 | 118 | /** 119 | * Get the name of the patch. 120 | * 121 | * @param string $path 122 | * 123 | * @return string 124 | */ 125 | public function getPatchName(string $path): string 126 | { 127 | return str_replace('.php', '', basename($path)); 128 | } 129 | 130 | /** 131 | * Require in all the patch files in a given path. 132 | * 133 | * @param array $files 134 | * 135 | * @return void 136 | * @throws FileNotFoundException 137 | */ 138 | public function requireFiles(array $files): void 139 | { 140 | foreach ($files as $file) { 141 | $this->files->requireOnce($file); 142 | } 143 | } 144 | 145 | /** 146 | * Resolve a patch instance from a file. 147 | * 148 | * @param string $file 149 | * 150 | * @return object 151 | */ 152 | public function resolve(string $file): object 153 | { 154 | $class = Str::studly(implode('_', array_slice(explode('_', $file), 4))); 155 | 156 | return new $class; 157 | } 158 | 159 | /** 160 | * Run the specified method on the patch 161 | * 162 | * @param object $patch 163 | * @param string $method 164 | * @param string|null $name 165 | * @param int|null $batch 166 | * 167 | * @return array{log: array|null, executionTime: int, memoryUsed: float, exception: Throwable|null} 168 | */ 169 | public function runPatch(object $patch, string $method, ?string $name = null, ?int $batch = null): array 170 | { 171 | if (! method_exists($patch, $method)) { 172 | return [ 173 | 'log' => null, 174 | 'executionTime' => 0, 175 | 'memoryUsed' => 0.0, 176 | 'exception' => null, 177 | ]; 178 | } 179 | 180 | $startTime = microtime(true); 181 | $startMemory = memory_get_peak_usage(true); 182 | $exception = null; 183 | $log = null; 184 | 185 | // Dispatch executing event 186 | if ($name && $batch && $method === 'up') { 187 | event(new PatchExecuting($name, $batch, $patch)); 188 | } elseif ($name && $method === 'down') { 189 | event(new PatchRollingBack($name, $patch)); 190 | } 191 | 192 | try { 193 | // Determine if we should use transactions 194 | $useTransaction = $this->shouldUseTransaction($patch); 195 | 196 | if ($useTransaction) { 197 | DB::transaction(function () use ($patch, $method) { 198 | $patch->{$method}(); 199 | }); 200 | } else { 201 | $patch->{$method}(); 202 | } 203 | 204 | $log = $patch->log; 205 | } catch (Throwable $e) { 206 | $exception = $e; 207 | $log = $patch->log ?? []; 208 | } 209 | 210 | $executionTime = (int) ((microtime(true) - $startTime) * 1000); 211 | $memoryUsed = (memory_get_peak_usage(true) - $startMemory) / 1024 / 1024; 212 | 213 | // Dispatch completion events 214 | if ($name && $batch) { 215 | if ($exception) { 216 | event(new PatchFailed($name, $batch, $exception, $executionTime)); 217 | } elseif ($method === 'up') { 218 | event(new PatchExecuted($name, $batch, $log, $executionTime, $memoryUsed)); 219 | } elseif ($method === 'down') { 220 | event(new PatchRolledBack($name, $executionTime)); 221 | } 222 | } 223 | 224 | return [ 225 | 'log' => $log, 226 | 'executionTime' => $executionTime, 227 | 'memoryUsed' => round($memoryUsed, 2), 228 | 'exception' => $exception, 229 | ]; 230 | } 231 | 232 | /** 233 | * Determine if the patch should use a transaction. 234 | * 235 | * @param object $patch 236 | * 237 | * @return bool 238 | */ 239 | protected function shouldUseTransaction(object $patch): bool 240 | { 241 | // Check if patch has useTransaction property 242 | if (property_exists($patch, 'useTransaction')) { 243 | $rp = new \ReflectionProperty($patch, 'useTransaction'); 244 | $rp->setAccessible(true); 245 | 246 | if ($rp->getValue($patch) !== null) { 247 | return $rp->getValue($patch); 248 | } 249 | } 250 | 251 | // Fall back to config 252 | return (bool) config('laravel-patches.use_transactions', false); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Events 3 | weight: 8 4 | --- 5 | 6 | Laravel Patches dispatches events throughout the patch lifecycle, allowing you to hook into the execution process for logging, notifications, monitoring, or custom behavior. 7 | 8 | ## Available Events 9 | 10 | ### PatchExecuting 11 | 12 | Dispatched before a patch's `up()` method runs. 13 | 14 | **Properties:** 15 | - `string $patch` - The patch name 16 | - `int $batch` - The batch number 17 | - `object $instance` - The patch instance 18 | 19 | **Example:** 20 | 21 | ```php 22 | use Rappasoft\LaravelPatches\Events\PatchExecuting; 23 | 24 | Event::listen(PatchExecuting::class, function (PatchExecuting $event) { 25 | Log::info("About to run patch: {$event->patch} in batch {$event->batch}"); 26 | 27 | // Send notification 28 | Notification::send($admins, new PatchStarting($event->patch)); 29 | }); 30 | ``` 31 | 32 | --- 33 | 34 | ### PatchExecuted 35 | 36 | Dispatched after a patch successfully executes. 37 | 38 | **Properties:** 39 | - `string $patch` - The patch name 40 | - `int $batch` - The batch number 41 | - `?array $log` - The patch logs 42 | - `int $executionTime` - Execution time in milliseconds 43 | - `float $memoryUsed` - Memory used in MB 44 | 45 | **Example:** 46 | 47 | ```php 48 | use Rappasoft\LaravelPatches\Events\PatchExecuted; 49 | 50 | Event::listen(PatchExecuted::class, function (PatchExecuted $event) { 51 | Log::info("Patch {$event->patch} completed in {$event->executionTime}ms"); 52 | 53 | // Alert if patch took too long 54 | if ($event->executionTime > 30000) { // 30 seconds 55 | Slack::send("Slow patch detected: {$event->patch} took {$event->executionTime}ms"); 56 | } 57 | 58 | // Log metrics to monitoring service 59 | Metrics::timing('patch.execution_time', $event->executionTime, [ 60 | 'patch' => $event->patch, 61 | 'batch' => $event->batch, 62 | ]); 63 | }); 64 | ``` 65 | 66 | --- 67 | 68 | ### PatchFailed 69 | 70 | Dispatched when a patch throws an exception. 71 | 72 | **Properties:** 73 | - `string $patch` - The patch name 74 | - `int $batch` - The batch number 75 | - `Throwable $exception` - The exception that was thrown 76 | - `int $executionTime` - Time before failure in milliseconds 77 | 78 | **Example:** 79 | 80 | ```php 81 | use Rappasoft\LaravelPatches\Events\PatchFailed; 82 | 83 | Event::listen(PatchFailed::class, function (PatchFailed $event) { 84 | Log::error("Patch {$event->patch} failed", [ 85 | 'exception' => $event->exception->getMessage(), 86 | 'trace' => $event->exception->getTraceAsString(), 87 | 'batch' => $event->batch, 88 | ]); 89 | 90 | // Send urgent notification 91 | Notification::send($admins, new PatchFailedNotification($event)); 92 | 93 | // Create rollback plan 94 | IssueTracker::create([ 95 | 'title' => "Patch failed: {$event->patch}", 96 | 'description' => $event->exception->getMessage(), 97 | 'severity' => 'critical', 98 | ]); 99 | }); 100 | ``` 101 | 102 | --- 103 | 104 | ### PatchRollingBack 105 | 106 | Dispatched before a patch's `down()` method runs. 107 | 108 | **Properties:** 109 | - `string $patch` - The patch name 110 | - `object $instance` - The patch instance 111 | 112 | **Example:** 113 | 114 | ```php 115 | use Rappasoft\LaravelPatches\Events\PatchRollingBack; 116 | 117 | Event::listen(PatchRollingBack::class, function (PatchRollingBack $event) { 118 | Log::warning("Rolling back patch: {$event->patch}"); 119 | 120 | // Backup data if needed 121 | if ($event->instance instanceof CriticalPatch) { 122 | BackupService::createSnapshot(); 123 | } 124 | }); 125 | ``` 126 | 127 | --- 128 | 129 | ### PatchRolledBack 130 | 131 | Dispatched after a patch successfully rolls back. 132 | 133 | **Properties:** 134 | - `string $patch` - The patch name 135 | - `int $executionTime` - Rollback time in milliseconds 136 | 137 | **Example:** 138 | 139 | ```php 140 | use Rappasoft\LaravelPatches\Events\PatchRolledBack; 141 | 142 | Event::listen(PatchRolledBack::class, function (PatchRolledBack $event) { 143 | Log::info("Rolled back patch: {$event->patch} in {$event->executionTime}ms"); 144 | 145 | Cache::tags('patches')->flush(); 146 | }); 147 | ``` 148 | 149 | --- 150 | 151 | ## Registering Event Listeners 152 | 153 | ### In EventServiceProvider 154 | 155 | ```php 156 | use Rappasoft\LaravelPatches\Events\{ 157 | PatchExecuting, 158 | PatchExecuted, 159 | PatchFailed, 160 | PatchRollingBack, 161 | PatchRolledBack 162 | }; 163 | 164 | protected $listen = [ 165 | PatchExecuting::class => [ 166 | LogPatchExecution::class, 167 | NotifyAdministrators::class, 168 | ], 169 | PatchExecuted::class => [ 170 | RecordMetrics::class, 171 | ClearCache::class, 172 | ], 173 | PatchFailed::class => [ 174 | AlertTeam::class, 175 | LogFailure::class, 176 | ], 177 | ]; 178 | ``` 179 | 180 | ### Using Closures 181 | 182 | ```php 183 | // In AppServiceProvider boot() method 184 | Event::listen(PatchExecuted::class, function ($event) { 185 | // Handle event 186 | }); 187 | ``` 188 | 189 | ### Using Event Subscribers 190 | 191 | ```php 192 | class PatchEventSubscriber 193 | { 194 | public function handlePatchExecuting(PatchExecuting $event): void 195 | { 196 | // ... 197 | } 198 | 199 | public function handlePatchExecuted(PatchExecuted $event): void 200 | { 201 | // ... 202 | } 203 | 204 | public function handlePatchFailed(PatchFailed $event): void 205 | { 206 | // ... 207 | } 208 | 209 | public function subscribe(Dispatcher $events): array 210 | { 211 | return [ 212 | PatchExecuting::class => 'handlePatchExecuting', 213 | PatchExecuted::class => 'handlePatchExecuted', 214 | PatchFailed::class => 'handlePatchFailed', 215 | ]; 216 | } 217 | } 218 | ``` 219 | 220 | --- 221 | 222 | ## Common Use Cases 223 | 224 | ### 1. Performance Monitoring 225 | 226 | ```php 227 | Event::listen(PatchExecuted::class, function ($event) { 228 | Metrics::histogram('patch.duration', $event->executionTime); 229 | Metrics::histogram('patch.memory', $event->memoryUsed); 230 | }); 231 | ``` 232 | 233 | ### 2. Slack Notifications 234 | 235 | ```php 236 | Event::listen(PatchExecuted::class, function ($event) { 237 | Slack::send("✅ Patch completed: {$event->patch} ({$event->executionTime}ms)"); 238 | }); 239 | 240 | Event::listen(PatchFailed::class, function ($event) { 241 | Slack::send("❌ Patch failed: {$event->patch}\n{$event->exception->getMessage()}"); 242 | }); 243 | ``` 244 | 245 | ### 3. Audit Logging 246 | 247 | ```php 248 | Event::listen([PatchExecuted::class, PatchFailed::class], function ($event) { 249 | AuditLog::create([ 250 | 'action' => 'patch_executed', 251 | 'patch' => $event->patch, 252 | 'batch' => $event->batch, 253 | 'status' => $event instanceof PatchFailed ? 'failed' : 'success', 254 | 'user' => auth()->user()?->email, 255 | 'timestamp' => now(), 256 | ]); 257 | }); 258 | ``` 259 | 260 | ### 4. Cache Invalidation 261 | 262 | ```php 263 | Event::listen(PatchExecuted::class, function ($event) { 264 | // Clear specific cache after certain patches 265 | if (str_contains($event->patch, 'cache')) { 266 | Cache::flush(); 267 | } 268 | }); 269 | ``` 270 | 271 | ### 5. Database Backups 272 | 273 | ```php 274 | Event::listen(PatchExecuting::class, function ($event) { 275 | // Backup before critical patches 276 | if ($event->instance->isCritical()) { 277 | Artisan::call('snapshot:create'); 278 | } 279 | }); 280 | ``` 281 | 282 | --- 283 | 284 | ## Event Data Access 285 | 286 | All events are serializable and can be queued: 287 | 288 | ```php 289 | Event::listen(PatchExecuted::class, function ($event) { 290 | ProcessPatchMetrics::dispatch($event)->onQueue('metrics'); 291 | }); 292 | ``` 293 | 294 | --- 295 | 296 | ## Disabling Events 297 | 298 | If you need to run patches without triggering events: 299 | 300 | ```php 301 | Event::fake([ 302 | PatchExecuting::class, 303 | PatchExecuted::class, 304 | ]); 305 | 306 | Artisan::call('patch'); 307 | ``` 308 | 309 | Or in tests: 310 | 311 | ```php 312 | Event::fake(); 313 | // Run patches 314 | Event::assertDispatched(PatchExecuted::class); 315 | ``` 316 | -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Error Handling 3 | weight: 9 4 | --- 5 | 6 | Laravel Patches provides comprehensive error handling to ensure patches fail gracefully and provide detailed information for troubleshooting. 7 | 8 | ## Error Behavior 9 | 10 | ### Default Behavior 11 | 12 | By default, if a patch throws an exception: 13 | 14 | 1. **Execution stops immediately** 15 | 2. **Error is logged** to the patches table 16 | 3. **Exception is re-thrown** 17 | 4. **No subsequent patches run** 18 | 19 | ### Continue on Error 20 | 21 | You can configure patches to continue running even when one fails: 22 | 23 | ```php 24 | // config/laravel-patches.php 25 | return [ 26 | 'stop_on_error' => false, // Continue running patches after failures 27 | ]; 28 | ``` 29 | 30 | Or use environment variable: 31 | 32 | ```bash 33 | PATCHES_STOP_ON_ERROR=false 34 | ``` 35 | 36 | --- 37 | 38 | ## Error Logging 39 | 40 | ### Automatic Error Capture 41 | 42 | When a patch fails, the following information is automatically captured: 43 | 44 | - Error message 45 | - Stack trace 46 | - Execution time before failure 47 | - Memory usage 48 | - User who ran the patch 49 | - Environment (production, staging, etc.) 50 | 51 | ### Database Storage 52 | 53 | Failed patches are stored in the patches table with: 54 | 55 | ```php 56 | [ 57 | 'status' => 'failed', 58 | 'error_message' => 'Exception message', 59 | 'error_trace' => 'Full stack trace', 60 | 'execution_time_ms' => 1234, 61 | ] 62 | ``` 63 | 64 | ### Viewing Failed Patches 65 | 66 | ```bash 67 | # See all patches including failed ones 68 | php artisan patch:status 69 | 70 | # Filter only failed patches 71 | php artisan patch:list --status=failed 72 | ``` 73 | 74 | --- 75 | 76 | ## Configuration 77 | 78 | ### config/laravel-patches.php 79 | 80 | ```php 81 | return [ 82 | /** 83 | * Stop execution on first error 84 | */ 85 | 'stop_on_error' => env('PATCHES_STOP_ON_ERROR', true), 86 | 87 | /** 88 | * Log errors to patches table 89 | */ 90 | 'log_errors' => env('PATCHES_LOG_ERRORS', true), 91 | ]; 92 | ``` 93 | 94 | --- 95 | 96 | ## Handling Errors in Patches 97 | 98 | ### Try-Catch Blocks 99 | 100 | Handle specific errors within your patch: 101 | 102 | ```php 103 | use Rappasoft\LaravelPatches\Patch; 104 | 105 | class MyPatch extends Patch 106 | { 107 | public function up() 108 | { 109 | try { 110 | // Risky operation 111 | User::where('email', 'LIKE', '%old-domain.com') 112 | ->update(['email' => DB::raw("REPLACE(email, 'old-domain.com', 'new-domain.com')")]); 113 | 114 | $this->log('Updated user emails'); 115 | } catch (\Exception $e) { 116 | $this->log('Error updating emails: ' . $e->getMessage()); 117 | 118 | // Optionally rethrow 119 | throw $e; 120 | } 121 | } 122 | 123 | public function down() 124 | { 125 | // Rollback logic 126 | } 127 | } 128 | ``` 129 | 130 | ### Validation Before Execution 131 | 132 | Validate data before making changes: 133 | 134 | ```php 135 | public function up() 136 | { 137 | // Check if operation is safe 138 | if (User::whereNull('email')->exists()) { 139 | throw new \Exception('Cannot proceed: Users with null emails exist'); 140 | } 141 | 142 | // Proceed with patch 143 | // ... 144 | } 145 | ``` 146 | 147 | ### Progressive Error Handling 148 | 149 | ```php 150 | public function up() 151 | { 152 | $errors = []; 153 | $processed = 0; 154 | 155 | User::chunk(100, function ($users) use (&$errors, &$processed) { 156 | foreach ($users as $user) { 157 | try { 158 | $user->update(['status' => 'active']); 159 | $processed++; 160 | } catch (\Exception $e) { 161 | $errors[] = "User {$user->id}: {$e->getMessage()}"; 162 | } 163 | } 164 | }); 165 | 166 | $this->log("Processed: {$processed} users"); 167 | 168 | if (count($errors)) { 169 | $this->log("Errors: " . implode(', ', $errors)); 170 | throw new \Exception(count($errors) . ' users failed to update'); 171 | } 172 | } 173 | ``` 174 | 175 | --- 176 | 177 | ## Event-Based Error Handling 178 | 179 | Subscribe to the `PatchFailed` event for custom error handling: 180 | 181 | ```php 182 | use Rappasoft\LaravelPatches\Events\PatchFailed; 183 | 184 | Event::listen(PatchFailed::class, function (PatchFailed $event) { 185 | // Send alert 186 | Notification::send( 187 | User::admins()->get(), 188 | new PatchFailedNotification($event->patch, $event->exception) 189 | ); 190 | 191 | // Log to external service 192 | Bugsnag::notifyException($event->exception, [ 193 | 'patch' => $event->patch, 194 | 'batch' => $event->batch, 195 | ]); 196 | 197 | // Create rollback plan 198 | DB::table('patch_failures')->insert([ 199 | 'patch' => $event->patch, 200 | 'error' => $event->exception->getMessage(), 201 | 'needs_manual_intervention' => true, 202 | 'created_at' => now(), 203 | ]); 204 | }); 205 | ``` 206 | 207 | --- 208 | 209 | ## Best Practices 210 | 211 | ### 1. Use Descriptive Error Messages 212 | 213 | ```php 214 | throw new \Exception("Failed to update user #{$user->id}: email validation failed"); 215 | ``` 216 | 217 | ### 2. Log Progress 218 | 219 | ```php 220 | $this->log("Processing 1000 records..."); 221 | // Process records 222 | $this->log("Completed successfully"); 223 | ``` 224 | 225 | ### 3. Validate Preconditions 226 | 227 | ```php 228 | public function up() 229 | { 230 | if (! Schema::hasColumn('users', 'old_field')) { 231 | throw new \Exception('old_field column does not exist'); 232 | } 233 | 234 | // Proceed with migration 235 | } 236 | ``` 237 | 238 | ### 4. Use Transactions (see Transactions documentation) 239 | 240 | ```php 241 | protected bool $useTransaction = true; 242 | ``` 243 | 244 | ### 5. Test Error Scenarios 245 | 246 | ```php 247 | // In your tests 248 | test('handles duplicate email error', function () { 249 | User::factory()->create(['email' => 'test@example.com']); 250 | 251 | $this->expectException(\Exception::class); 252 | 253 | Artisan::call('patch'); 254 | }); 255 | ``` 256 | 257 | --- 258 | 259 | ## Debugging Failed Patches 260 | 261 | ### 1. Check Logs 262 | 263 | ```bash 264 | # Laravel logs 265 | tail -f storage/logs/laravel.log 266 | ``` 267 | 268 | ### 2. Query Failed Patches 269 | 270 | ```php 271 | use Rappasoft\LaravelPatches\Models\Patch; 272 | 273 | $failed = Patch::where('status', 'failed')->get(); 274 | 275 | foreach ($failed as $patch) { 276 | dump($patch->patch); 277 | dump($patch->error_message); 278 | dump($patch->error_trace); 279 | } 280 | ``` 281 | 282 | ### 3. Re-run Individual Patch 283 | 284 | Create a temporary command to re-run a specific patch: 285 | 286 | ```php 287 | // app/Console/Commands/RerunPatch.php 288 | public function handle() 289 | { 290 | $patchName = $this->argument('patch'); 291 | 292 | // Delete old failed attempt 293 | Patch::where('patch', $patchName)->delete(); 294 | 295 | // Re-run the patch 296 | Artisan::call('patch'); 297 | } 298 | ``` 299 | 300 | --- 301 | 302 | ## Recovery Strategies 303 | 304 | ### After a Failed Patch 305 | 306 | 1. **Review the error** in `patch:status` or database 307 | 2. **Understand the cause** from error message and trace 308 | 3. **Fix the data or code** that caused the failure 309 | 4. **Rollback if needed** with `patch:rollback` 310 | 5. **Fix the patch file** if it contains bugs 311 | 6. **Re-run patches** with `php artisan patch` 312 | 313 | ### Manual Intervention 314 | 315 | If a patch partially completed before failing: 316 | 317 | ```php 318 | public function up() 319 | { 320 | // Check what was already done 321 | if (DB::table('temp_migration_state')->exists()) { 322 | $this->log('Resuming from previous failed attempt'); 323 | // Continue from checkpoint 324 | } else { 325 | // Start fresh 326 | } 327 | 328 | // Your patch logic with checkpoints 329 | } 330 | ``` 331 | 332 | --- 333 | 334 | ## Production Safeguards 335 | 336 | ### 1. Always Test First 337 | 338 | ```bash 339 | # Test in staging 340 | php artisan patch --dry-run 341 | php artisan patch 342 | ``` 343 | 344 | ### 2. Backup Before Running 345 | 346 | ```bash 347 | php artisan snapshot:create 348 | php artisan patch 349 | ``` 350 | 351 | ### 3. Monitor Execution 352 | 353 | ```bash 354 | # Run with verbose output 355 | php artisan patch -v 356 | ``` 357 | 358 | ### 4. Have Rollback Plan 359 | 360 | Ensure your `down()` methods can properly rollback: 361 | 362 | ```php 363 | public function down() 364 | { 365 | // Reverse the changes from up() 366 | } 367 | ``` 368 | -------------------------------------------------------------------------------- /docs/transactions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Transactions 3 | weight: 10 4 | --- 5 | 6 | Laravel Patches supports wrapping patch execution in database transactions, providing automatic rollback on failures and ensuring data consistency. 7 | 8 | ## Overview 9 | 10 | When transactions are enabled, if a patch throws an exception, all database changes made by that patch are automatically rolled back. 11 | 12 | --- 13 | 14 | ## Enabling Transactions 15 | 16 | ### Per-Patch Basis 17 | 18 | Enable transactions for a specific patch: 19 | 20 | ```php 21 | use Rappasoft\LaravelPatches\Patch; 22 | 23 | class MyPatch extends Patch 24 | { 25 | protected bool $useTransaction = true; 26 | 27 | public function up() 28 | { 29 | // All database operations here will be wrapped in a transaction 30 | User::where('type', 'old')->update(['type' => 'new']); 31 | Setting::create(['key' => 'version', 'value' => '2.0']); 32 | 33 | // If anything fails, everything rolls back 34 | } 35 | 36 | public function down() 37 | { 38 | User::where('type', 'new')->update(['type' => 'old']); 39 | Setting::where('key', 'version')->delete(); 40 | } 41 | } 42 | ``` 43 | 44 | ### Globally 45 | 46 | Enable transactions for all patches by default: 47 | 48 | ```php 49 | // config/laravel-patches.php 50 | return [ 51 | 'use_transactions' => env('PATCHES_USE_TRANSACTIONS', false), 52 | ]; 53 | ``` 54 | 55 | Or in `.env`: 56 | 57 | ```bash 58 | PATCHES_USE_TRANSACTIONS=true 59 | ``` 60 | 61 | --- 62 | 63 | ## How It Works 64 | 65 | When `$useTransaction = true`: 66 | 67 | ```php 68 | DB::transaction(function () { 69 | $patch->up(); 70 | }); 71 | ``` 72 | 73 | If an exception is thrown: 74 | 1. All database changes are rolled back 75 | 2. The exception is re-thrown 76 | 3. No changes persist to the database 77 | 78 | --- 79 | 80 | ## When to Use Transactions 81 | 82 | ### ✅ Use Transactions When: 83 | 84 | - **Updating multiple related records** 85 | ```php 86 | public function up() 87 | { 88 | User::all()->each(function ($user) { 89 | $user->profile->update(['verified' => true]); 90 | $user->update(['status' => 'active']); 91 | }); 92 | } 93 | ``` 94 | 95 | - **Data must be consistent** 96 | ```php 97 | public function up() 98 | { 99 | // Transfer data between tables 100 | OldTable::chunk(100, function ($records) { 101 | foreach ($records as $record) { 102 | NewTable::create($record->toArray()); 103 | $record->delete(); 104 | } 105 | }); 106 | } 107 | ``` 108 | 109 | - **Testing patches** (easy rollback) 110 | ```php 111 | protected bool $useTransaction = true; // For development 112 | ``` 113 | 114 | ### ❌ Avoid Transactions When: 115 | 116 | - **Running long operations** 117 | ```php 118 | // This will lock tables for extended period 119 | User::chunk(1000, function ($users) { /* ... */ }); 120 | ``` 121 | 122 | - **Making external API calls** 123 | ```php 124 | public function up() 125 | { 126 | // Transactions won't rollback API calls 127 | Http::post('api.example.com/update', $data); 128 | } 129 | ``` 130 | 131 | - **Using DDL statements** (some databases) 132 | ```php 133 | public function up() 134 | { 135 | // Schema changes may auto-commit 136 | Schema::create('new_table', function ($table) { 137 | // ... 138 | }); 139 | } 140 | ``` 141 | 142 | - **Processing huge datasets** 143 | ```php 144 | // Memory and lock issues 145 | Model::all()->each(function ($record) { /* ... */ }); 146 | ``` 147 | 148 | --- 149 | 150 | ## Configuration Priority 151 | 152 | The priority order for determining transaction usage: 153 | 154 | 1. **Patch-level** `$useTransaction` property (highest priority) 155 | 2. **Config** `laravel-patches.use_transactions` 156 | 3. **Default** `false` (lowest priority) 157 | 158 | Example: 159 | 160 | ```php 161 | // Config says TRUE 162 | 'use_transactions' => true, 163 | 164 | // But patch says FALSE - patch wins 165 | class MyPatch extends Patch 166 | { 167 | protected bool $useTransaction = false; // This takes precedence 168 | } 169 | ``` 170 | 171 | --- 172 | 173 | ## Manual Transaction Control 174 | 175 | For complex scenarios, you can manually control transactions: 176 | 177 | ```php 178 | use Rappasoft\LaravelPatches\Patch; 179 | use Illuminate\Support\Facades\DB; 180 | 181 | class ComplexPatch extends Patch 182 | { 183 | protected bool $useTransaction = false; // Disable automatic 184 | 185 | public function up() 186 | { 187 | // Part 1 - in transaction 188 | DB::transaction(function () { 189 | User::where('type', 'A')->update(['status' => 'active']); 190 | }); 191 | 192 | // Part 2 - NOT in transaction (external API) 193 | Http::post('api.example.com/notify'); 194 | 195 | // Part 3 - in new transaction 196 | DB::transaction(function () { 197 | Log::create(['action' => 'patch_completed']); 198 | }); 199 | } 200 | } 201 | ``` 202 | 203 | --- 204 | 205 | ## Nested Transactions 206 | 207 | Laravel uses savepoints for nested transactions: 208 | 209 | ```php 210 | public function up() 211 | { 212 | DB::transaction(function () { 213 | User::create(['name' => 'John']); 214 | 215 | DB::transaction(function () { 216 | // Nested - uses savepoint 217 | Profile::create(['user_id' => 1]); 218 | }); 219 | }); 220 | } 221 | ``` 222 | 223 | --- 224 | 225 | ## Handling Transaction Failures 226 | 227 | ### Catching and Logging 228 | 229 | ```php 230 | use Rappasoft\LaravelPatches\Patch; 231 | 232 | class SafePatch extends Patch 233 | { 234 | protected bool $useTransaction = true; 235 | 236 | public function up() 237 | { 238 | try { 239 | User::where('old_status', 'pending') 240 | ->update(['status' => 'active']); 241 | 242 | $this->log('Updated user statuses'); 243 | } catch (\Exception $e) { 244 | $this->log('Failed to update: ' . $e->getMessage()); 245 | throw $e; // Re-throw to trigger rollback 246 | } 247 | } 248 | } 249 | ``` 250 | 251 | ### Partial Rollback 252 | 253 | ```php 254 | public function up() 255 | { 256 | // This succeeds and commits 257 | DB::transaction(function () { 258 | User::create(['name' => 'Alice']); 259 | }); 260 | 261 | // This fails and rolls back (only its changes) 262 | try { 263 | DB::transaction(function () { 264 | User::create(['email' => 'invalid']); // Fails validation 265 | }); 266 | } catch (\Exception $e) { 267 | $this->log('Second transaction failed, but first succeeded'); 268 | } 269 | } 270 | ``` 271 | 272 | --- 273 | 274 | ## Performance Considerations 275 | 276 | ### Large Datasets 277 | 278 | For large datasets, use chunking WITHOUT wrapping everything in one transaction: 279 | 280 | ```php 281 | class LargeDataPatch extends Patch 282 | { 283 | protected bool $useTransaction = false; 284 | 285 | public function up() 286 | { 287 | User::chunk(500, function ($users) { 288 | // Each chunk in its own transaction 289 | DB::transaction(function () use ($users) { 290 | foreach ($users as $user) { 291 | $user->update(['migrated' => true]); 292 | } 293 | }); 294 | }); 295 | } 296 | } 297 | ``` 298 | 299 | ### Deadlock Prevention 300 | 301 | ```php 302 | public function up() 303 | { 304 | // Process in smaller batches to reduce lock contention 305 | $retries = 3; 306 | 307 | User::chunk(100, function ($users) use (&$retries) { 308 | try { 309 | DB::transaction(function () use ($users) { 310 | foreach ($users as $user) { 311 | $user->processUpdate(); 312 | } 313 | }); 314 | } catch (\Illuminate\Database\QueryException $e) { 315 | if ($e->getCode() === '40001' && $retries > 0) { // Deadlock 316 | $retries--; 317 | sleep(1); 318 | // Retry logic 319 | } else { 320 | throw $e; 321 | } 322 | } 323 | }); 324 | } 325 | ``` 326 | 327 | --- 328 | 329 | ## Testing with Transactions 330 | 331 | Transactions are especially useful in testing: 332 | 333 | ```php 334 | test('patch updates users correctly', function () { 335 | $patch = new MyPatch(); 336 | $patch->useTransaction = true; 337 | 338 | try { 339 | $patch->up(); 340 | $this->fail('Expected exception was not thrown'); 341 | } catch (\Exception $e) { 342 | // Transaction rolled back automatically 343 | $this->assertEquals(0, User::where('migrated', true)->count()); 344 | } 345 | }); 346 | ``` 347 | 348 | --- 349 | 350 | ## Database-Specific Behavior 351 | 352 | ### MySQL/MariaDB 353 | 354 | - DDL statements (CREATE, ALTER, DROP) cause implicit commits 355 | - Transactions work well for DML (INSERT, UPDATE, DELETE) 356 | 357 | ### PostgreSQL 358 | 359 | - Full support for transactional DDL 360 | - Can rollback schema changes 361 | 362 | ### SQLite 363 | 364 | - Transactions work for all operations 365 | - Good for testing 366 | 367 | --- 368 | 369 | ## Best Practices 370 | 371 | 1. **Enable for critical patches** 372 | ```php 373 | protected bool $useTransaction = true; // For data integrity 374 | ``` 375 | 376 | 2. **Disable for long-running patches** 377 | ```php 378 | protected bool $useTransaction = false; // Prevent long locks 379 | ``` 380 | 381 | 3. **Use manual control when needed** 382 | ```php 383 | // Mix transactional and non-transactional code 384 | ``` 385 | 386 | 4. **Test both success and failure scenarios** 387 | ```php 388 | test('rolls back on failure', function () { /* ... */ }); 389 | ``` 390 | 391 | 5. **Monitor transaction duration** 392 | ```php 393 | Event::listen(PatchExecuted::class, function ($event) { 394 | if ($event->executionTime > 10000) { 395 | Log::warning("Long transaction: {$event->patch}"); 396 | } 397 | }); 398 | ``` 399 | 400 | 6. **Document transaction usage** 401 | ```php 402 | /** 403 | * Updates user roles across multiple tables. 404 | * Uses transactions to ensure consistency. 405 | */ 406 | protected bool $useTransaction = true; 407 | ``` 408 | 409 | --- 410 | 411 | ## Troubleshooting 412 | 413 | ### Transaction Timeout 414 | 415 | If patches take too long: 416 | 417 | ```php 418 | DB::statement('SET SESSION max_execution_time = 300000'); // 5 minutes 419 | ``` 420 | 421 | ### Lock Wait Timeout 422 | 423 | ```php 424 | DB::statement('SET SESSION innodb_lock_wait_timeout = 120'); 425 | ``` 426 | 427 | ### Checking Transaction Status 428 | 429 | ```php 430 | public function up() 431 | { 432 | if (DB::transactionLevel() > 0) { 433 | $this->log('Currently in a transaction'); 434 | } 435 | } 436 | ``` 437 | --------------------------------------------------------------------------------