shipu / watchable
Requires
None
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-27 12:20:47 UTC
README
Watchable is a Laravel package where you can easily pick up laravel model event and activity log inside your application.
use Shipu\Watchable\Traits\WatchableTrait;
onModelCreating
onModelCreated
onModelUpdating
onModelUpdated
The Package stores all activity in the activity_logs table.
Here's a demo of how you can use it:
activity()->log('Look, I logged something');
You can retrieve all activity using the Shipu\Watchable\Models\Activity model.
Activity::all();
Here's a more advanced example:
activity() ->on($anEloquentModel) ->data($storeWhatYouWantTo) ->log('Look, I logged something'); $lastLoggedActivity = Activity::all()->last(); $lastLoggedActivity->model; //returns an instance of an eloquent model $lastLoggedActivity->causer; //returns an instance of your user model $lastLoggedActivity->remarks; //returns 'Look, I logged something'
Here's an example on event logging.
$user->name = 'updated name'; $user->save(); //updating the newsItem will cause the logging of an activity $activity = Activity::all()->last(); $activity->remarks; //returns 'User Updated' $activity->model; //returns the instance of NewsItem that was created
Calling $activity->changes will return this array:
[ 'new' => [ 'name' => 'updated name', 'text' => 'Lorum', ], 'old' => [ 'name' => 'original name', 'text' => 'Lorum', ], ];
Installation
You can install the package via composer:
composer require shipu/watchable
You can optionally publish the config file with:
php artisan vendor:publish --provider="Shipu\Watchable\WatchableServiceProvider" --tag="shipu-watchable-config"
This is the contents of the published config file:
return [ 'audit_columns' => [ 'creator_column' => 'creator', 'editor_column' => 'editor', 'default_active' => false, ], 'activity_log' => [ 'model' => \Shipu\Watchable\Models\Activity::class ] ];
You can publish the migration with:
php artisan vendor:publish --provider="Shipu\Watchable\WatchableServiceProvider" --tag="shipu-watchable-migrations"
Note: The default migration assumes you are using integers for your model IDs. If you are using UUIDs, or some other format, adjust the format of the subject_id and causer_id fields in the published migration before continuing.
After publishing the migration you can create the activity_logs table by running the migrations:
php artisan migrate
Partial indexes
Laravel's schema builder has no fluent way to create a partial index (an index limited to rows
matching a WHERE predicate — e.g. a unique slug that's only enforced while deleted_at is null).
This has been proposed upstream three times and closed unmerged each time (see
laravel/framework#61007,
#61097,
#61098) — Taylor Otwell's guidance was to ship it
as a package instead, so here it is.
Supported on pgsql and sqlite only (both compile the same where syntax). Throws a
RuntimeException on mysql/sqlsrv rather than emit incorrect SQL.
IDE autocomplete: since these methods are added at runtime (macros, and — for the chained
->unique()->where(...) form — a class swap your IDE can't see statically), plain PHP static
analysis won't know about them and will flag Method 'where' not found. The package ships
_ide_helper_watchable.php at its root for exactly this — the same stub technique
barryvdh/laravel-ide-helper uses. Most IDEs
(PhpStorm included) index it automatically once the package is installed; no extra dependency or
config needed. It's inert — never required, not in the autoload map, purely for the IDE to read.
Chained directly onto Laravel's own unique() / index()
Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('slug')->unique()->where(fn ($query) => $query->whereNull('deleted_at')); $table->string('status'); $table->boolean('is_active')->default(true); $table->index('status')->where(fn ($query) => $query->where('is_active', true)); });
Plain ->unique()/->index() with no ->where() chained behaves exactly like stock Laravel —
this doesn't change anything about columns that don't opt in.
This needs more than a macro pair (see the "How this works" sections below): Laravel's own
$table->string('slug')->unique() doesn't create a real command at the point you call it — it just
sets a flag, silently promoted into a real index command after the whole Schema::create() closure
finishes, by which point there's nothing left to chain ->where(...) onto. Making that chain actually
work requires:
- Swapping
Blueprint's column-definition class so->unique()/->index()queue a real command immediately (viaSchema::blueprintResolver()) - Swapping the schema grammar so
compileUnique()/compileIndex()know to look for awhereattribute (real method overrides — a grammar macro can't reach existing methods, established further down) - Swapping the whole
Connectionclass per driver (viaConnection::resolverFor()) to actually get that custom grammar used — there's no narrower hook than this
That last swap is real added surface, not just more code: every query on a pgsql/sqlite connection
now runs through a Connection subclass this package owns, not Laravel's own. It's opt-in via
config('watchable.partial_indexes.enabled') (defaults to true) precisely because of that — set it
to false and only the original $table->partialUnique()/partialIndex() macros remain active,
with zero effect on unique()/index().
Postgres-specific correctness note: $table->unique() compiles to
alter table ... add constraint ... unique (...) — a table constraint, and Postgres constraint
syntax has no WHERE clause at all. Appending one would be a syntax error. So on Postgres, the
override doesn't append to the constraint form when ->where(...) is present — it emits
create unique index ... where ... instead, the only Postgres syntax that supports a partial
predicate. (SQLite's native compileUnique() is already a plain create unique index statement, so
appending is safe there.) Verified against a real Postgres database, not just SQLite in CI.
Declared as its own method, inside the Schema::create() closure
Three ways to supply the predicate:
use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('slug'); $table->string('status'); $table->integer('position'); $table->softDeletes(); // 1. Raw string, 3rd argument. $table->partialUnique('slug', 'uniq_products_slug', 'deleted_at is null'); // 2. Raw string, chained. $table->partialUnique('slug', 'uniq_products_slug')->where('deleted_at is null'); // 3. Closure — receives a real Illuminate\Database\Query\Builder. Safe for values // from user input; bindings are inlined via Laravel's own binding-substitution // logic (the same code behind Builder::toRawSql()), not string concatenation. $table->partialUnique('slug', 'uniq_products_slug') ->where(fn ($query) => $query->whereNull('deleted_at')); // Non-unique conditional index — same three forms. $table->partialIndex(['status', 'position'], 'idx_products_listing') ->where(fn ($query) => $query->whereNull('deleted_at')); // No predicate at all — still compiles as `create unique index`, never as a table // CONSTRAINT, so (unlike Laravel's native ->unique()) it accepts expressions: // Postgres constraint syntax only allows plain column names, no exceptions. $table->partialUnique([new Expression('COALESCE(parent_id, 0)'), 'slug'], 'uniq_topic_slug_per_parent'); });
Drop them the same way you'd drop any index — no special method needed, dropIndex() already
works for a partial index by name:
Schema::table('products', function (Blueprint $table) { $table->dropIndex('uniq_products_slug'); });
The raw-string forms (1 and 2) are not parameterized — build them from fixed strings in your migration, never from user input. Reach for the closure form (3) whenever a value in the predicate could come from outside your codebase.
How this works without touching Laravel core
This needs two macros, not one, because of how Schema::create() actually executes:
Blueprint::macro('partialUnique', ...)— called inside the closure, it just queues a command via$this->addCommand(...), exactly like Laravel's own$table->unique()does. TheCREATE TABLEstatement is also just a queued command at this point (added first, before your closure runs) — nothing has hit the database yet.Grammar::macro('compilePartialUnique', ...)— Laravel dispatches each queued command to SQL via$grammar->{'compile'.ucfirst($command->name)}(...), checked withmethod_exists(...) || $grammar::hasMacro(...). Registering acompilePartialUniquemacro on the baseIlluminate\Database\Schema\Grammars\Grammarclass means it's picked up by that dispatch exactly like a real compiler method, for every driver — the driver subclasses don't redeclare theMacroabletrait, so macro storage is shared across all of them (verified: registering a macro onPostgresGrammarmakesMySqlGrammar::hasMacro(...)true too). That's why the compiler macro does its owninstanceof PostgresGrammar || instanceof SQLiteGrammarcheck and throws otherwise, rather than being "not registered" for unsupported drivers.
Execution order is safe because Schema::create() always queues the create command before running
your closure ($blueprint->create(); $callback($blueprint);), and Blueprint::build() executes each
compiled statement in queue order — so by the time compilePartialUnique's CREATE INDEX statement
runs, CREATE TABLE has already run on the connection.
A Blueprint-only macro can't do this on its own: even if it queued a command, Blueprint has no
way to teach PostgresGrammar/SQLiteGrammar a new compilePartialUnique method — that dispatch is
a hardcoded method lookup on the grammar class, which is exactly why the 3 rejected upstream PRs had
to edit PostgresGrammar.php/SQLiteGrammar.php/SqlServerGrammar.php directly instead of shipping
as a package. Grammar macros are the loophole that makes a package-only implementation possible.
Why the chained ->where(...) form needed no third macro
addCommand() returns a plain Illuminate\Support\Fluent — and Fluent doesn't declare a
where() method of its own, so ->where(...) chained onto it lands on Fluent's own magic
__call(), which just stores whatever was passed as the where attribute. That's the exact same
$command->where property the 3-argument form sets directly — no extra registration needed, it
falls out of how Fluent already works.
This is also why $table->unique(...)->where(...) (chaining onto Laravel's own unique())
doesn't and can't work: Blueprint::unique() returns an IndexDefinition, whose command is named
'unique', dispatching to the real compileUnique — a method that already exists on
PostgresGrammar/SQLiteGrammar and therefore can never be reached through __call/macro dispatch
(PHP resolves a real declared method before ever consulting magic methods). A where attribute set
on that command would be silently ignored by the native compiler. Reusing unique()'s own name was
never on the table without editing Laravel core; partialUnique had to be a distinct command with
its own compiler from the start.
The closure form (->where(fn ($query) => ...)) builds the predicate with a real, disconnected
Illuminate\Database\Query\Builder, compiles it via compileWheres(), then inlines the resulting
bindings with Grammar::substituteBindingsIntoRawSql() — the same method backing
Builder::toRawSql(). That's a real safety difference from the raw-string forms, not just a style
choice: naive string interpolation (or even a hand-rolled str_replace('?', ...)) breaks on a value
containing a literal quote or Postgres's ?/?? operators; substituteBindingsIntoRawSql() already
handles both correctly, so this reuses it instead of re-implementing it.
CHECK constraints
Laravel's schema builder has no fluent support for CHECK constraints at all — nothing in Column
Modifiers, no $table->check(). Unlike partial indexes, CHECK is standard, portable SQL every
driver Laravel supports understands, so this doesn't need partialUnique()'s driver restriction —
and since check isn't an existing Blueprint method, there's nothing to fight over method_exists()
precedence with the way ->unique()->where(...) did. A plain macro pair is enough.
Schema::create('orders', function (Blueprint $table) { $table->id(); $table->decimal('subtotal', 10, 2); $table->decimal('discount_amount', 10, 2)->default(0); $table->decimal('total', 10, 2); $table->check('total = subtotal - discount_amount AND total >= 0', 'chk_total'); });
The name is required, not auto-generated — it's meant to match whatever your schema documentation already calls it, the same reasoning behind every other named-index feature in this package.
Schema::table('orders', function (Blueprint $table) { $table->dropCheck('chk_total'); });
Native dropIndex()/dropUnique() both assume they're dropping an index; on Postgres that compiles
to drop index, which fails against a real table constraint ("chk_total" is not an index).
dropCheck() is the matching counterpart, compiling to alter table ... drop constraint ....
Verified enforced (not silently ignored) on both pgsql and sqlite — including SQLite, where
ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) genuinely works and is enforced, despite SQLite's
famously limited ALTER TABLE support elsewhere.
Raw-type columns
Not a gap this package needs to fill — Laravel's own Blueprint already has an escape hatch for
column types it has no method for (Postgres extension types like ltree, array types like
bigint[], a bare unscaled numeric): $table->rawColumn($name, $definition). Its typeRaw() is a
real method on Grammar (not something to macro), so use that directly — no watchable API needed
here.