├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci-phpstan.yml │ └── ci-tests.yml ├── .gitignore ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── docs └── logo.png ├── phpstan.neon ├── phpunit.xml ├── src ├── Console │ └── Commands │ │ └── RedactCommand.php ├── Events │ └── ModelRedacted.php ├── Exceptions │ └── RedactableFieldsException.php ├── Interfaces │ ├── Redactable.php │ └── RedactionStrategy.php ├── RedactableModelsProvider.php ├── Support │ ├── Redactor.php │ └── Strategies │ │ ├── FakeStrategy.php │ │ ├── HashContents.php │ │ ├── MaskContents.php │ │ └── ReplaceContents.php └── Traits │ └── HasRedactableFields.php └── tests ├── Data └── Models │ ├── Post.php │ └── User.php ├── Feature ├── Support │ ├── Redactor │ │ └── RedactTest.php │ └── Strategies │ │ ├── HashContents │ │ └── HashContentsTest.php │ │ ├── MaskContents │ │ └── MaskContentsTest.php │ │ └── ReplaceContents │ │ └── ReplaceContentsTest.php └── Traits │ └── HasRedactableFields │ └── RedactFieldsTest.php └── TestCase.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ash-jc-allen 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci-phpstan.yml: -------------------------------------------------------------------------------- 1 | name: run-phpstan 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.2, 8.3] 13 | laravel: [10.*, 11.*] 14 | include: 15 | - laravel: 11.* 16 | testbench: 9.* 17 | - laravel: 10.* 18 | testbench: 8.* 19 | 20 | name: PHP${{ matrix.php }} - Laravel ${{ matrix.laravel }} 21 | 22 | steps: 23 | - name: Update apt 24 | run: sudo apt-get update --fix-missing 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | - name: Setup PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | coverage: none 34 | 35 | - name: Setup Problem Matches 36 | run: | 37 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 38 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 39 | 40 | - name: Install dependencies 41 | run: | 42 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 43 | composer update --prefer-dist --no-interaction --no-suggest 44 | - name: Run Larastan 45 | run: vendor/bin/phpstan analyse --error-format=github 46 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.2, 8.3] 13 | laravel: [10.*, 11.*] 14 | include: 15 | - laravel: 11.* 16 | testbench: 9.* 17 | - laravel: 10.* 18 | testbench: 8.* 19 | 20 | name: PHP${{ matrix.php }} - Laravel ${{ matrix.laravel }} 21 | 22 | steps: 23 | - name: Update apt 24 | run: sudo apt-get update --fix-missing 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | - name: Setup PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | coverage: none 34 | 35 | - name: Setup Problem Matches 36 | run: | 37 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 38 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 39 | 40 | - name: Install dependencies 41 | run: | 42 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 43 | composer update --prefer-dist --no-interaction --no-suggest 44 | - name: Execute tests 45 | run: vendor/bin/phpunit 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | composer.lock 4 | .phpunit.result.cache 5 | .phpunit.cache 6 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **0.1.0 (released 2024-07-27):** 4 | 5 | - Initial release. 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ashley Allen 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Redactable Models for Laravel 3 |

4 | 5 |

6 | Latest Version on Packagist 7 | Total Downloads 8 | PHP from Packagist 9 | GitHub license 10 |

11 | 12 | ## Table of Contents 13 | 14 | - [Overview](#overview) 15 | - [Installation](#installation) 16 | - [Requirements](#requirements) 17 | - [Install the Package](#install-the-package) 18 | - [Usage](#usage) 19 | - [Defining Redactable Models](#defining-redactable-models) 20 | - [The `model:redact` Command](#the-modelredact-command) 21 | - [Redaction Strategies](#redaction-strategies) 22 | - [`ReplaceContents`](#replacecontents) 23 | - [`HashContents`](#hashcontents) 24 | - [`MaskContents`](#maskcontents) 25 | - [Custom Redaction Strategies](#custom-redaction-strategies) 26 | - [Manually Redacting Models](#manually-redacting-models) 27 | - [Events](#events) 28 | - [`ModelRedacted`](#modelredacted) 29 | - [Testing](#testing) 30 | - [Security](#security) 31 | - [Credits](#credits) 32 | - [Changelog](#changelog) 33 | - [License](#license) 34 | 35 | ## Overview 36 | 37 | > [!IMPORTANT] 38 | > This package is still in development. There may be some bugs and API changes before the first stable release. 39 | 40 | A Laravel package that you can use to redact, obfuscate, or mask fields from your models in a consistent and easy way. 41 | 42 | When building web applications, you'll often need to keep hold of old data for auditing or reporting purposes. But for data privacy and security reasons, you may want to redact the sensitive information from the data that you store. This way, you can keep the rows in the database, but without the sensitive information. 43 | 44 | This package allows you to define which models and fields should be redacted, and how they should be redacted. 45 | 46 | ## Installation 47 | 48 | ### Requirements 49 | 50 | The package has been developed and tested to work with the following minimum requirements: 51 | 52 | - PHP 8.2 53 | - Laravel 10 54 | 55 | ### Install the Package 56 | 57 | You can install the package via Composer: 58 | 59 | ```bash 60 | composer require ashallendesign/redactable-models 61 | ``` 62 | 63 | ## Usage 64 | 65 | ### Defining Redactable Models 66 | 67 | In order to make a model redactable, you need to add the `AshAllenDesign\RedactableModels\Interfaces\Redactable` interface to the model. This will enforce two new methods (`redactable` and `redactionStrategy`) that you need to implement. 68 | 69 | Your model may look something like so: 70 | 71 | ```php 72 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 73 | use Illuminate\Contracts\Database\Eloquent\Builder 74 | 75 | class User extends Model implements Redactable 76 | { 77 | // ... 78 | 79 | public function redactable(): Builder 80 | { 81 | // ... 82 | } 83 | 84 | public function redactionStrategy(): RedactionStrategy 85 | { 86 | // ... 87 | } 88 | } 89 | ``` 90 | 91 | The `redactable` method allows you to return an instance of `Illuminate\Contracts\Database\Eloquent\Builder` which defines the models that are redactable. 92 | 93 | The `redactionStrategy` method allows you to return an instance of `AshAllenDesign\RedactableModels\Interfaces\RedactionStrategy` which defines how the fields should be redacted. We'll cover the available strategies further down. 94 | 95 | As an example, if we wanted to redact the `email` and `name` fields from all `App\Models\User` models older than 30 days, we could do the following: 96 | 97 | ```php 98 | use AshAllenDesign\RedactableModels\Support\Strategies\ReplaceContents; 99 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 100 | use Illuminate\Contracts\Database\Eloquent\Builder 101 | 102 | class User extends Model implements Redactable 103 | { 104 | // ... 105 | 106 | public function redactable(): Builder 107 | { 108 | return static::query()->where('created_at', '<', now()->subDays(30)); 109 | } 110 | 111 | public function redactionStrategy(): RedactionStrategy 112 | { 113 | return app(ReplaceContents::class)->replaceWith([ 114 | 'name' => 'REDACTED', 115 | 'email' => 'redacted@redacted.com', 116 | ]); 117 | } 118 | } 119 | ``` 120 | 121 | ### The `model:redact` Command 122 | 123 | In order to automatically redact the fields on the models, you can use the package's `model:redact` command like so: 124 | 125 | ```text 126 | php artisan model:redact 127 | ``` 128 | 129 | This will find all the models within your app's `app/Models` directory that implement the `AshAllenDesign\RedactableModels\Interfaces\Redactable` interface and redact the fields based on the defined redaction strategy and query. 130 | 131 | You may want to set this to run on a schedule (such as on a daily basis) in your Laravel app's [scheduler](https://laravel.com/docs/11.x/scheduling). 132 | 133 | ### Redaction Strategies 134 | 135 | The package ships with several strategies that you can use redacting fields: 136 | 137 | #### `ReplaceContents` 138 | 139 | The `ReplaceContents` strategy allows you to replace the contents of the fields with a specified value. 140 | 141 | For example, if we wanted to replace the `name` and `email` fields, we could do the following: 142 | 143 | ```php 144 | use AshAllenDesign\RedactableModels\Support\Strategies\ReplaceContents; 145 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 146 | use Illuminate\Foundation\Auth\User as Authenticatable; 147 | 148 | class User extends Authenticatable implements Redactable 149 | { 150 | // ... 151 | 152 | public function redactionStrategy(): RedactionStrategy 153 | { 154 | return app(ReplaceContents::class)->replaceWith([ 155 | 'name' => 'REDACTED', 156 | 'email' => 'redacted@redacted.com', 157 | ]); 158 | } 159 | } 160 | ``` 161 | 162 | Running this against a model would replace the `name` field with `REDACTED` and the `email` field with `redacted@redacted.com`. 163 | 164 | The `ReplaceContents` strategy also allows you to use a closure to define the replacement value. This can be useful if you want a bit more control over the redaction process. The closure should accept the model as an argument and have a `void` return type. 165 | 166 | For example, say you want to replace the `name` field with `name_` followed by their ID. You could do the following: 167 | 168 | ```php 169 | use AshAllenDesign\RedactableModels\Support\Strategies\ReplaceContents; 170 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 171 | use Illuminate\Foundation\Auth\User as Authenticatable; 172 | 173 | class User extends Authenticatable implements Redactable 174 | { 175 | // ... 176 | 177 | public function redactionStrategy(): RedactionStrategy 178 | { 179 | return app(ReplaceContents::class)->replaceWith(function (User $user): void { 180 | $user->name = 'name_'.$user->id; 181 | }); 182 | } 183 | } 184 | ``` 185 | 186 | Imagine we have a user with ID `123` and a `name` of `John Doe`. Running the above code would replace the `name` field with `name_123`. 187 | 188 | #### `HashContents` 189 | 190 | The `HashContents` strategy allows you to MD5 hash the contents of the field. 191 | 192 | This can be useful when you still want to be able to compare the redacted fields, but don't want to expose the original data. 193 | 194 | For example, imagine you have an `invitations` table that contains an `email` field. You may want to find out how many unique email addresses have been invited, but you don't want to expose the email addresses themselves. You could do the following: 195 | 196 | ```php 197 | use AshAllenDesign\RedactableModels\Support\Strategies\HashContents; 198 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 199 | use Illuminate\Database\Eloquent\Model; 200 | 201 | class Invitation extends Model implements Redactable 202 | { 203 | // ... 204 | 205 | public function redactionStrategy(): RedactionStrategy 206 | { 207 | return app(HashContents::class))->fields([ 208 | 'email', 209 | ]); 210 | } 211 | } 212 | ``` 213 | 214 | #### `MaskContents` 215 | 216 | The `MaskContents` strategy allows you to mask the contents of the field with a specified character. 217 | 218 | You can define the character to use for the mask, and how many characters from the start and end of the field to leave unmasked like so: 219 | 220 | ```php 221 | use AshAllenDesign\RedactableModels\Support\Strategies\MaskContents; 222 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 223 | use Illuminate\Foundation\Auth\User as Authenticatable; 224 | 225 | class User extends Authenticatable implements Redactable 226 | { 227 | // ... 228 | 229 | public function redactionStrategy(): RedactionStrategy 230 | { 231 | return app(MaskContents::class) 232 | ->mask(field: 'name', character: '*', index: 0, length: 4) 233 | ->mask(field: 'email', character: '-', index: 2, length: 3); 234 | } 235 | } 236 | ``` 237 | 238 | In the above example, the `name` field would be masked with `*` and the first 4 characters would be left unmasked. The `email` field would be masked with `-` and the first 2 and last 3 characters would be left unmasked. 239 | 240 | This means if a user's name was "Ash Allen" and their email was "ash@example.com", after redaction their name would be "****Allen" and their email would be "as---xample.com". 241 | 242 | #### Custom Redaction Strategies 243 | 244 | Although the package ships with several redaction strategies out of the box, you can create your own custom redaction strategies. 245 | 246 | You just need to create a class that implements the `AshAllenDesign\RedactableModels\Interfaces\RedactionStrategy` interface. This method enforces an `apply` method which accepts the model and defines the redaction logic. 247 | 248 | An example of a custom redaction strategy might look like so: 249 | 250 | ```php 251 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 252 | use AshAllenDesign\RedactableModels\Interfaces\RedactionStrategy; 253 | use Illuminate\Database\Eloquent\Model; 254 | 255 | class CustomStrategy implements RedactionStrategy 256 | { 257 | public function apply(Redactable&Model $model): void 258 | { 259 | // Redaction logic goes here 260 | } 261 | } 262 | ``` 263 | 264 | ### Manually Redacting Models 265 | 266 | There may be times when you want to manually redact a model rather than using the `model:redact` command. To do this you can use the `redactFields` method that is available via the `AshAllenDesign\RedactableModels\Traits\HasRedactableFields` trait. 267 | 268 | You can apply the trait to your model like so: 269 | 270 | ```php 271 | use AshAllenDesign\RedactableModels\Interfaces\Redactable; 272 | use AshAllenDesign\RedactableModels\Traits\HasRedactableFields; 273 | use Illuminate\Foundation\Auth\User as Authenticatable; 274 | 275 | class User extends Authenticatable implements Redactable 276 | { 277 | use HasRedactableFields; 278 | 279 | // ... 280 | } 281 | ``` 282 | 283 | You can now use the `redactFields` method to redact the fields on the model like so: 284 | 285 | ```php 286 | $user = User::find(1); 287 | 288 | $user->redactFields(); 289 | ``` 290 | 291 | By default, this will redact the fields using the strategy defined in the model's `redactionStrategy` method. 292 | 293 | You can override this by passing a custom redaction strategy to the `redactFields` method like so: 294 | 295 | ```php 296 | use App\Models\User; 297 | use AshAllenDesign\RedactableModels\Support\Strategies\ReplaceContents; 298 | 299 | $user = User::find(1); 300 | 301 | $user->redactFields( 302 | strategy: app(ReplaceContents::class)->replaceWith(['name' => 'REDACTED']) 303 | ); 304 | ``` 305 | 306 | ### Events 307 | 308 | #### `ModelRedacted` 309 | 310 | When a model is redacted, an `AshAllenDesign\RedactableModels\Events\ModelRedacted` event is fired that can be listened on. 311 | 312 | ## Testing 313 | 314 | To run the package's unit tests, run the following command: 315 | 316 | ``` bash 317 | vendor/bin/phpunit 318 | ``` 319 | 320 | ## Security 321 | 322 | If you find any security related issues, please contact me directly at [mail@ashallendesign.co.uk](mailto:mail@ashallendesign.co.uk) to report it. 323 | 324 | ## Contribution 325 | 326 | If you wish to make any changes or improvements to the package, feel free to make a pull request. 327 | 328 | Note: A contribution guide will be added soon. 329 | 330 | ## Credits 331 | 332 | - [Ash Allen](https://ashallendesign.co.uk) 333 | - [Jess Allen](https://jesspickup.co.uk) (Logo) 334 | - [All Contributors](https://github.com/ash-jc-allen/redactable-models/graphs/contributors) 335 | 336 | ## Changelog 337 | 338 | Check the [CHANGELOG](CHANGELOG.md) to get more information about the latest changes. 339 | 340 | ## License 341 | 342 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 343 | 344 | ## Support Me 345 | 346 | If you've found this package useful, please consider buying a copy of [Battle Ready Laravel](https://battle-ready-laravel.com) to support me and my work. 347 | 348 | Every sale makes a huge difference to me and allows me to spend more time working on open-source projects and tutorials. 349 | 350 | To say a huge thanks, you can use the code **BATTLE20** to get a 20% discount on the book. 351 | 352 | [👉 Get Your Copy!](https://battle-ready-laravel.com) 353 | 354 | [![Battle Ready Laravel](https://ashallendesign.co.uk/images/custom/sponsors/battle-ready-laravel-horizontal-banner.png)](https://battle-ready-laravel.com) 355 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ashallendesign/redactable-models", 3 | "description": "A Laravel package for easily redacting model data.", 4 | "type": "library", 5 | "homepage": "https://github.com/ash-jc-allen/redactable-models", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Ash Allen", 10 | "email": "mail@ashallendesign.co.uk" 11 | } 12 | ], 13 | "keywords": [ 14 | "ashallendesign", 15 | "laravel", 16 | "models", 17 | "redactable-models" 18 | ], 19 | "require": { 20 | "php": "^8.2", 21 | "laravel/framework": "^10.0|^11.0" 22 | }, 23 | "require-dev": { 24 | "mockery/mockery": "^1.0", 25 | "orchestra/testbench": "^8.0|^9.0", 26 | "phpunit/phpunit": "^10.0|^11.0", 27 | "nunomaduro/larastan": "^2.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "AshAllenDesign\\RedactableModels\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "AshAllenDesign\\RedactableModels\\Tests\\": "tests/" 37 | } 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "AshAllenDesign\\RedactableModels\\RedactableModelsProvider" 43 | ] 44 | } 45 | }, 46 | "scripts": { 47 | "test": "vendor/bin/phpunit", 48 | "larastan": "vendor/bin/phpstan analyse" 49 | }, 50 | "minimum-stability": "dev", 51 | "prefer-stable": true 52 | } 53 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-jc-allen/redactable-models/d46852247d888fa7f354b5ab710ee6d0880e584e/docs/logo.png -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | level: 2 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | src/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Console/Commands/RedactCommand.php: -------------------------------------------------------------------------------- 1 | redactableModels(); 24 | 25 | foreach ($models as $model) { 26 | $this->redactModel($model); 27 | } 28 | 29 | return static::SUCCESS; 30 | } 31 | 32 | private function redactModel(string $model): void 33 | { 34 | $redactor = app(Redactor::class); 35 | 36 | /** @var Redactable $instance */ 37 | $instance = new $model; 38 | 39 | $strategy = $instance->redactionStrategy(); 40 | 41 | $models = $instance->redactable()->get(); 42 | 43 | $this->components->info('Redacting ['.$models->count().'] ['.$model.'] models.'); 44 | 45 | $models->map(function (Redactable $model) use ($redactor, $strategy): void { 46 | $redactor->redact($model, $strategy); 47 | }); 48 | } 49 | 50 | /** 51 | * @return Collection 52 | */ 53 | private function redactableModels(): Collection 54 | { 55 | return collect((new Finder())->in(app_path('Models'))->files()->name('*.php')) 56 | ->map(function (SplFileInfo $model): string { 57 | $namespace = $this->laravel->getNamespace(); 58 | 59 | return $namespace.str_replace( 60 | ['/', '.php'], 61 | ['\\', ''], 62 | Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) 63 | ); 64 | })->filter(function (string $model): bool { 65 | return class_exists($model); 66 | })->filter(function (string $model): bool { 67 | return $this->isRedactable($model); 68 | })->values(); 69 | } 70 | 71 | /** 72 | * Determine if the given model class is redactable. 73 | * 74 | * @param string $model 75 | * @return bool 76 | */ 77 | private function isRedactable(string $model): bool 78 | { 79 | $interfaces = class_implements($model); 80 | 81 | return in_array(Redactable::class, $interfaces, strict: true); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Events/ModelRedacted.php: -------------------------------------------------------------------------------- 1 | commands([ 20 | RedactCommand::class, 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Support/Redactor.php: -------------------------------------------------------------------------------- 1 | apply($model); 16 | 17 | event(new ModelRedacted($model)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Support/Strategies/FakeStrategy.php: -------------------------------------------------------------------------------- 1 | applied = true; 19 | } 20 | 21 | public function assertHasBeenApplied(): void 22 | { 23 | Assert::assertTrue( 24 | $this->applied, 25 | 'The redaction strategy has not been applied.', 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Support/Strategies/HashContents.php: -------------------------------------------------------------------------------- 1 | fields as $field) { 21 | $model->{$field} = hash(algo: 'md5', data: $model->{$field}); 22 | } 23 | 24 | $model->save(); 25 | } 26 | 27 | /** 28 | * @param string[] $fields 29 | * @return $this 30 | */ 31 | public function fields(array $fields): static 32 | { 33 | $this->fields = $fields; 34 | 35 | return $this; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Support/Strategies/MaskContents.php: -------------------------------------------------------------------------------- 1 | masks as $mask) { 19 | $value = $model->{$mask['field']}; 20 | 21 | $maskedValue = Str::mask( 22 | string: $value, 23 | character: $mask['character'], 24 | index: $mask['index'], 25 | length: $mask['length'], 26 | encoding: $mask['encoding'] 27 | ); 28 | 29 | $model->{$mask['field']} = $maskedValue; 30 | } 31 | 32 | $model->save(); 33 | } 34 | 35 | public function mask(string $field, string $character, int $index, int $length = null, string $encoding = 'UTF-8'): static 36 | { 37 | $this->masks[] = [ 38 | 'field' => $field, 39 | 'character' => $character, 40 | 'index' => $index, 41 | 'length' => $length, 42 | 'encoding' => $encoding, 43 | ]; 44 | 45 | return $this; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Support/Strategies/ReplaceContents.php: -------------------------------------------------------------------------------- 1 | |Closure(Model): void 16 | */ 17 | private array|Closure $replaceWithMappings; 18 | 19 | public function apply(Redactable&Model $model): void 20 | { 21 | is_array($this->replaceWithMappings) 22 | ? $model->forceFill($this->replaceWithMappings) 23 | : ($this->replaceWithMappings)($model); 24 | 25 | $model->save(); 26 | } 27 | 28 | /** 29 | * @param array|Closure(Model $model): void $replaceWith 30 | * @return $this 31 | */ 32 | public function replaceWith(array|Closure $replaceWith): static 33 | { 34 | $this->replaceWithMappings = $replaceWith; 35 | 36 | return $this; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Traits/HasRedactableFields.php: -------------------------------------------------------------------------------- 1 | redact( 24 | model: $this, 25 | strategy: $strategy ?? $this->redactionStrategy(), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Data/Models/Post.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected $fillable = [ 20 | 'email', 21 | ]; 22 | 23 | /** 24 | * The attributes that should be hidden for serialization. 25 | * 26 | * @var array 27 | */ 28 | protected $hidden = [ 29 | 'password', 30 | 'remember_token', 31 | ]; 32 | 33 | /** 34 | * Get the attributes that should be cast. 35 | * 36 | * @return array 37 | */ 38 | protected function casts(): array 39 | { 40 | return [ 41 | 'email_verified_at' => 'datetime', 42 | 'password' => 'hashed', 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Data/Models/User.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected $fillable = [ 24 | 'email', 25 | ]; 26 | 27 | /** 28 | * The attributes that should be hidden for serialization. 29 | * 30 | * @var array 31 | */ 32 | protected $hidden = [ 33 | 'password', 34 | 'remember_token', 35 | ]; 36 | 37 | /** 38 | * Get the attributes that should be cast. 39 | * 40 | * @return array 41 | */ 42 | protected function casts(): array 43 | { 44 | return [ 45 | 'email_verified_at' => 'datetime', 46 | 'password' => 'hashed', 47 | ]; 48 | } 49 | 50 | public function redactable(): Builder 51 | { 52 | // Dummy value set as placeholder. 53 | return static::query(); 54 | } 55 | 56 | public function redactionStrategy(): RedactionStrategy 57 | { 58 | // Dummy value set as placeholder. 59 | return (new ReplaceContents())->replaceWith(['name' => 'REDACTED']); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Feature/Support/Redactor/RedactTest.php: -------------------------------------------------------------------------------- 1 | redact($model, $strategy); 28 | 29 | $strategy->assertHasBeenApplied(); 30 | 31 | Event::assertDispatched( 32 | event: ModelRedacted::class, 33 | callback: fn (ModelRedacted $modelRedacted) => $modelRedacted->model->is($model) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/Support/Strategies/HashContents/HashContentsTest.php: -------------------------------------------------------------------------------- 1 | fields([ 19 | 'name', 20 | ]); 21 | 22 | $model = new User(); 23 | 24 | $model->name = 'Ash Allen'; 25 | $model->email = 'ash@example.com'; 26 | $model->password = 'password'; 27 | 28 | $model->save(); 29 | 30 | $strategy->apply($model); 31 | 32 | $model->refresh(); 33 | 34 | $this->assertSame('7659a2a904e2ac114d3de76d850ebd68', $model->name); 35 | $this->assertSame('ash@example.com', $model->email); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Feature/Support/Strategies/MaskContents/MaskContentsTest.php: -------------------------------------------------------------------------------- 1 | mask('name', '*', 0, 4); 19 | $strategy->mask('email', '-', 2, 3); 20 | 21 | $model = new User(); 22 | 23 | $model->name = 'Ash Allen'; 24 | $model->email = 'ash@example.com'; 25 | $model->password = 'password'; 26 | 27 | $model->save(); 28 | 29 | $strategy->apply($model); 30 | 31 | $model->refresh(); 32 | 33 | $this->assertSame('****Allen', $model->name); 34 | $this->assertSame('as---xample.com', $model->email); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/Support/Strategies/ReplaceContents/ReplaceContentsTest.php: -------------------------------------------------------------------------------- 1 | replaceWith(['name' => 'John Doe']); 19 | 20 | $model = new User(); 21 | 22 | $model->name = 'Ash Allen'; 23 | $model->email = 'ash@example.com'; 24 | $model->password = 'password'; 25 | 26 | $model->save(); 27 | 28 | $strategy->apply($model); 29 | 30 | $model->refresh(); 31 | 32 | $this->assertSame('John Doe', $model->name); 33 | $this->assertSame('ash@example.com', $model->email); 34 | } 35 | 36 | #[Test] 37 | public function models_can_be_redacted_using_closure(): void 38 | { 39 | $strategy = new ReplaceContents(); 40 | $strategy->replaceWith(function (User $user): void { 41 | $user->name = 'name_'.$user->id; 42 | $user->email = $user->id.'@example.com'; 43 | }); 44 | 45 | $model = new User(); 46 | 47 | $model->id = 123; 48 | $model->name = 'Ash Allen'; 49 | $model->email = 'ash@example.com'; 50 | $model->password = 'password'; 51 | 52 | $model->save(); 53 | 54 | $strategy->apply($model); 55 | 56 | $model->refresh(); 57 | 58 | $this->assertSame('name_123', $model->name); 59 | $this->assertSame('123@example.com', $model->email); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Feature/Traits/HasRedactableFields/RedactFieldsTest.php: -------------------------------------------------------------------------------- 1 | name = 'Ash Allen'; 23 | $model->email = 'ash@example.com'; 24 | $model->password = 'password'; 25 | 26 | $model->save(); 27 | 28 | $model->redactFields(); 29 | 30 | $model->refresh(); 31 | 32 | $this->assertSame('REDACTED', $model->name); 33 | } 34 | 35 | #[Test] 36 | public function model_can_be_redacted_using_strategy_passed_to_method(): void 37 | { 38 | $model = new User(); 39 | 40 | $model->name = 'Ash Allen'; 41 | $model->email = 'ash@example.com'; 42 | $model->password = 'password'; 43 | 44 | $model->save(); 45 | 46 | $model->redactFields((new ReplaceContents())->replaceWith(['name' => 'HELLO'])); 47 | 48 | $model->refresh(); 49 | 50 | $this->assertSame('HELLO', $model->name); 51 | } 52 | 53 | #[Test] 54 | public function exception_is_thrown_if_the_model_does_not_implement_the_redactable_interface(): void 55 | { 56 | $this->expectException(RedactableFieldsException::class); 57 | $this->expectExceptionMessage('The model must implement the [AshAllenDesign\RedactableModels\Interfaces\Redactable] interface.'); 58 | 59 | $model = new class extends Model 60 | { 61 | use HasRedactableFields; 62 | }; 63 | 64 | $model->redactFields(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |