PHPackages                             reynotech/laravel-approvals - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. reynotech/laravel-approvals

ActiveLibrary[Database &amp; ORM](/categories/database)

reynotech/laravel-approvals
===========================

Approval workflows for Eloquent models: capture pending changes, gate them behind N approvers/disapprovers, and apply the diff once approved.

v0.3.1(today)06↑2400%MITPHPPHP ^8.2CI passing

Since Jul 28Pushed todayCompare

[ Source](https://github.com/reynotech/laravel-approvals)[ Packagist](https://packagist.org/packages/reynotech/laravel-approvals)[ RSS](/packages/reynotech-laravel-approvals/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (5)Versions (5)Used By (0)

ReynoTECH Laravel Approvals
---------------------------

[](#reynotech-laravel-approvals)

Approval workflows for Eloquent models: capture a pending change as a `Modification`, gate it behind N approvers/disapprovers, and apply the diff once approved.

Extracted from `rex-server-next`'s `RequiresApprovalWhenChanges` / `ApprovesChanges` traits so the workflow can be unit tested in isolation and reused across apps.

📖 Full documentation: ****

### Requirements

[](#requirements)

- PHP 8.2+
- `illuminate/database` / `illuminate/support` ^11 || ^12

### Installation

[](#installation)

```
composer require reynotech/laravel-approvals
php artisan vendor:publish --tag=approvals-migrations
php artisan migrate
```

Optionally publish the config:

```
php artisan vendor:publish --tag=approvals-config
```

### Usage

[](#usage)

On the model whose changes should be gated:

```
use ReynoTECH\Approvals\Concerns\RequiresApprovalWhenChanges;
use ReynoTECH\Approvals\Contracts\Approvable;

class Client extends Model implements Approvable
{
    use RequiresApprovalWhenChanges;

    protected int $approversRequired = 1;
    protected int $disapproversRequired = 1;

    // Decide when a change needs approval instead of saving directly.
    protected function requiresApprovalWhenTrait(array $dirty): bool
    {
        return array_key_exists('billing_address', $dirty);
    }
}
```

On the model that casts approvals (e.g. `User`):

```
use ReynoTECH\Approvals\Concerns\ApprovesChanges;
use ReynoTECH\Approvals\Contracts\Approver;

class User extends Authenticatable implements Approver
{
    use ApprovesChanges;
}
```

```
$client->billing_address = $newAddress;
$client->save(); // captured as a pending Modification instead of persisted

$modification = $client->modifications()->activeOnly()->first();

$user->approve($modification, 'confirmed with client');
// once approvers_required is reached, the diff is applied and the row is updated
```

### The modification diff

[](#the-modification-diff)

`Modification::$modifications` stores one entry per changed attribute, each as a pair of the original and modified value:

```
[
    'status' => ['ori' => 'draft', 'mod' => 'active'],
]
```

- `ori` — the attribute's value before the change (from `getOriginal()`/`getRawOriginal()`).
- `mod` — the attribute's value after the change, i.e. what gets written back once approved.

For array-like/schemaless attributes, `ori`/`mod` only contain the sub-keys that actually changed rather than the whole structure.

The `ori`/`mod` key names can be renamed via config, in case they collide with something in your app:

```
// config/approvals.php
'diff_keys' => [
    'original' => 'ori',
    'modified' => 'mod',
],
```

### `Modification` as the single source of history

[](#modification-as-the-single-source-of-history)

Every save produces a `Modification`: `flow` is `direct` or `approval`, and `status` is one of `applied`, `pending`, `approved`, `rejected` or `partially_resolved`. `active`/`is_modification` are kept and still work exactly as before (they're derived from `flow`/`status`), but `flow`/`status` are the preferred contract going forward:

flowstatusactiveis\_modification`direct``applied`falsefalse`approval``pending`truetrue`approval``approved`/`rejected`/`partially_resolved`falsetrue### Per-item (partial) approval — opt-in

[](#per-item-partial-approval--opt-in)

By default a pending `Modification` is decided as a whole, exactly like before: `approve($modification)`/`disapprove($modification)` apply or discard the entire diff, and no `ModificationItem` rows are created. This keeps the common case (a single field, or "all or nothing") free of any extra rows — this package favors audit history over searchability, so it never creates a row it doesn't need.

When an operation can touch independently-decidable groups of fields (e.g. `rules` can be approved while `notes` is rejected in the same save), override `approvableItemGroups()` to opt in:

```
class Report extends Model implements Approvable
{
    use RequiresApprovalWhenChanges;

    protected function approvableItemGroups(array $dirtyKeys): array
    {
        return [
            'rules' => ['rules'],
            'notes' => ['notes'],
        ];
    }
}
```

Any dirty key not covered by a group stays bundled into the parent `Modification`'s own diff. Groups that are covered get one `ModificationItem` row each (not one row per field — a group can bundle several fields into a single JSON `original`/`proposed` pair, keeping row counts low), decided independently:

```
$modification = $report->modifications()->activeOnly()->first();
$rulesItem = $modification->items()->where('key', 'rules')->first();
$notesItem = $modification->items()->where('key', 'notes')->first();

$user->approveItem($rulesItem, 'rules look right');   // only `rules` is written back
$user->disapproveItem($notesItem, 'notes are wrong');  // `notes` is left untouched

$modification->fresh()->status; // 'partially_resolved'
```

`approve($modification)`/`disapprove($modification)` stay interoperable with this: called on a `Modification` that has items, they resolve every still-pending item atomically in one transaction, instead of requiring the caller to know about `ModificationItem` at all.

### Recording history outside of `save()`

[](#recording-history-outside-of-save)

For events that don't go through Eloquent's `saving` hook — creation, deletion, attachments, signatures, calls to external integrations — use `recordModification()`. It always records a `direct`/`applied` entry (it documents something that already happened; it doesn't gate anything) and runs inside whatever transaction the caller is already in:

```
$model->recordModification(
    eventType: 'attachment_added',
    items: [
        [
            'key' => 'attachments.contract',
            'original' => null,
            'proposed' => ['id' => 123, 'name' => 'contract.pdf'],
        ],
    ],
    context: ['label' => 'Contrato'],
    operationId: $operationId,
);
```

### `operation_id`

[](#operation_id)

Pass `withOperationId($id)` before saving (or to `recordModification()`) to tag every `Modification` produced by one logical operation with the same `operation_id`, so they can be correlated later even if some were applied directly and others went through approval.

### Extension points

[](#extension-points)

- `modifier()` — override to change how the "who made this change" resolver works (defaults to `auth()->user()`).
- `authorizedToApprove()` / `authorizedToDisapprove()` — override on the approver model to add role/policy checks.
- `config('approvals.models.*')` — swap in your own `Modification`/`ModificationItem`/`Approval`/`Disapproval` subclasses.
- `config('approvals.diff_keys.*')` — rename the `ori`/`mod` keys used in the stored diff (see above).
- `Concerns\HasModificationMedia` — optional trait to attach images to a pending `Modification` via `spatie/laravel-medialibrary` (not a hard dependency of this package).
- Diff application (`applyModificationChanges`) writes back to any attribute whose current value is an object exposing a `set($key, $value)` method (e.g. `spatie/laravel-schemaless-attributes`) instead of overwriting it outright.

### Known limitations

[](#known-limitations)

- Migration uses `nullableMorphs()`/`morphs()`, which assume integer/bigint primary keys on `modifiable`/`modifier`/`approver`/`disapprover`. UUID/ULID-keyed models are not supported out of the box.
- Only updates go through the approval gate; model creation always saves directly (matching the behavior this package was extracted from).

### Deprecations

[](#deprecations)

- `deleteWhenApproved` / `deleteWhenDisapproved` are deprecated: prefer letting the `Modification`/`ModificationItem` row stay and simply reading its `status`. They still work exactly as before.

### Upgrading from &lt; 0.3.0

[](#upgrading-from--030)

Publish and run the new migration:

```
php artisan vendor:publish --tag=approvals-migrations
php artisan migrate
```

It adds `flow`, `status`, `event_type`, `operation_id`, `context`, `occurred_at` to `modifications`, creates `modification_items`, and adds a nullable `modification_item_id` to `approvals`/`disapprovals`. Existing rows are backfilled automatically (`flow`/`status` derived from `active`/`is_modification`, and from vote counts for already-resolved rows). No consumer code changes are required: `active`, `is_modification`, `approve()`, `disapprove()` and the legacy diff format all keep working unchanged, and `modification_items` stays empty unless a model opts in via `approvableItemGroups()`.

### Testing

[](#testing)

```
composer test
```

### Credits

[](#credits)

- Inspired in part by [cloudcake/laravel-approval](https://github.com/cloudcake/laravel-approval).

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance100

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/5a54a0525bf964f79d90d7402dda39b9661c559f3b239a970eb122850c95b66a?d=identicon)[reynotech](/maintainers/reynotech)

---

Top Contributors

[![reynotech](https://avatars.githubusercontent.com/u/24636036?v=4)](https://github.com/reynotech "reynotech (6 commits)")

---

Tags

laraveleloquentworkflowmoderationapproval

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/reynotech-laravel-approvals/health.svg)

```
[![Health](https://phpackages.com/badges/reynotech-laravel-approvals/health.svg)](https://phpackages.com/packages/reynotech-laravel-approvals)
```

###  Alternatives

[mongodb/laravel-mongodb

A MongoDB based Eloquent model and Query builder for Laravel

7.1k8.4M99](/packages/mongodb-laravel-mongodb)[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k32.6M46](/packages/kirschbaum-development-eloquent-power-joins)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M348](/packages/psalm-plugin-laravel)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8793.2M25](/packages/yajra-laravel-oci8)[glushkovds/phpclickhouse-laravel

Adapter of the most popular library https://github.com/smi2/phpClickHouse to Laravel

2051.5M2](/packages/glushkovds-phpclickhouse-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
