User:
Created:
Updated:
Exported: 9/3/2025 16:28:02
Link: https://chatgpt.com/share/68b6c2c6-9994-8006-b477-1da22cd484a1
laravel filament versi 4, pada action default delete, saya ingin menambahkan activitylog namun saya tidak ingin menggunakan observer ?
Kalau kamu ingin menambahkan activitylog ke dalam action default Delete
di Laravel Filament v4 tanpa menggunakan Observer, kamu bisa override langsung DeleteAction
bawaan Filament pada resource terkait.
Contohnya, di dalam YourModelResource.php
:
use Filament\Tables;
use Filament\Tables\Actions\DeleteAction;
use Spatie\Activitylog\Models\Activity;
class YourModelResource extends Resource
{
public static function table(Table $table): Table
{
return $table
->columns([
// Kolom kamu...
])
->actions([
DeleteAction::make()
->after(function ($record) {
activity()
->performedOn($record)
->causedBy(auth()->user())
->withProperties([
'attributes' => $record->toArray(),
])
->log('Data dihapus melalui Filament');
}),
]);
}
}
DeleteAction::make()
→ memanggil default actiondelete
.->after()
→ callback setelah proses delete berhasil.activity()
→ fungsi dari package spatie/laravel-activitylog.->performedOn($record)
→ mengaitkan log ke model yang dihapus.->causedBy(auth()->user())
→ menyimpan siapa user yang melakukan aksi.->withProperties()
→ tambahan detail, misalnya snapshot data sebelum terhapus.->log('...')
→ deskripsi aktivitas.
Dengan cara ini, kamu tidak perlu Observer, tapi tetap bisa log otomatis setiap kali ada delete lewat Filament.
Mau saya buatin juga contoh untuk Create dan Edit action dengan pattern yang sama biar konsisten semua aktivitas tercatat?
bantu saya buatkan queue untuk handle activitylog ?
Thought for 22s
The user wants to handle activity log via queue in Laravel Filament v4. Spatie’s activity log package can be configured to queue logs, but I’m considering safer approaches, like creating a custom job (LogActivityJob
). This job would handle the snapshot and dispatch data for saving, ensuring everything’s serializable. I’ll look into using ShouldQueue
.
I need to create a LogActivityJob
for logging activity. The job would handle soft deletes by using withTrashed
and pass the necessary properties, including ID and type. For hard deletes, I’d use subject_type
and subject_id
directly, and consider using tapActivity
or custom properties to modify the log entry.
Done
Siap. Kita bikin Job khusus untuk men‐queue penulisan activity log, lalu panggil Job itu dari DeleteAction
(atau action lain) di Filament v4. Tanpa observer. Berikut pola yang aman untuk soft delete maupun hard delete.
- Buat Job ===========
php artisan make:job LogActivityJob
app/Jobs/LogActivityJob.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
class LogActivityJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public string $subjectClass;
public ?int $subjectId;
public ?int $causerId;
public string $description;
public ?string $event;
public array $properties;
public ?string $logName;
/**
* @param class-string<Model> $subjectClass
* @param array $properties // snapshot data, dll
*/
public function __construct(
string $subjectClass,
?int $subjectId,
?int $causerId,
string $description,
?string $event = null,
array $properties = [],
?string $logName = null
) {
$this->subjectClass = $subjectClass;
$this->subjectId = $subjectId;
$this->causerId = $causerId;
$this->description = $description;
$this->event = $event;
$this->properties = $properties;
$this->logName = $logName;
$this->onQueue('logging'); // optional: khusus antrian "logging"
// $this->onConnection('redis'); // optional: pilih koneksi
}
public function handle(): void
{
// Hindari load model besar-besaran — hanya ambil jika perlu
$causer = $this->causerId ? \App\Models\User::find($this->causerId) : null;
$subjectModel = null;
// Coba ambil subject dengan withTrashed (jika pakai SoftDeletes)
if ($this->subjectId) {
$builder = ($this->subjectClass)::query();
if (in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($this->subjectClass))) {
$builder->withTrashed();
}
$subjectModel = $builder->find($this->subjectId);
}
$logger = activity($this->logName)
->event($this->event)
->withProperties($this->properties);
if ($causer) {
$logger->causedBy($causer);
}
if ($subjectModel instanceof Model) {
// Normal case (termasuk soft-deleted)
$logger->performedOn($subjectModel)->log($this->description);
} else {
// Hard delete: subject sudah tidak ada. Simpan subject info di properties.
$logger
->withProperties(array_merge($this->properties, [
'_subject_type' => $this->subjectClass,
'_subject_id' => $this->subjectId,
'_note' => 'Subject tidak ditemukan saat penulisan log (kemungkinan hard delete).',
]))
->log($this->description);
}
}
}
Catatan:
- Jangan serialisasikan instance model besar ke job. Kirim class name + ID + snapshot
properties
saja (aman & ringan).- Jika Anda pakai SoftDeletes,
withTrashed()
memastikanperformedOn()
tetap bisa dipakai pasca delete.- Untuk hard delete, kita tetap tulis log dan menaruh
subject_type
/subject_id
diproperties
.
- Panggil dari Filament DeleteAction =====================================
Di YourModelResource
:
use Filament\Tables;
use Filament\Tables\Actions\DeleteAction;
use App\Jobs\LogActivityJob;
public static function table(\Filament\Tables\Table $table): \Filament\Tables\Table
{
return $table
->columns([
// ...
])
->actions([
DeleteAction::make()
->after(function ($record) {
// Ambil data sebelum terhapus (sudah ada di memori saat after())
$snapshot = $record->toArray();
LogActivityJob::dispatch(
subjectClass: get_class($record),
subjectId: $record->getKey(),
causerId: optional(auth()->user())->getKey(),
description: 'Data dihapus melalui Filament',
event: 'deleted',
properties: [
'attributes' => $snapshot, // snapshot terakhir
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'route' => request()->path(),
],
logName: 'default', // atau nama log khusus
);
}),
]);
}
- (Opsional) Panggil juga di Create/Update ===========================================
Contoh untuk EditAction
:
use Filament\Tables\Actions\EditAction;
// ...
EditAction::make()
->after(function ($record, array $data) {
LogActivityJob::dispatch(
subjectClass: get_class($record),
subjectId: $record->getKey(),
causerId: optional(auth()->user())->getKey(),
description: 'Data diperbarui melalui Filament',
event: 'updated',
properties: [
'new' => $data,
],
logName: 'default',
);
})
- Konfigurasi Queue (singkat) ==============================
.env
QUEUE_CONNECTION=database # atau redis, sqs, dll
- Jika pakai
database
:(urutkan agar antrianphp artisan queue:table && php artisan migrate php artisan queue:work --queue=logging,default
logging
diproses lebih dulu kalau mau)
- Tips Produksi ================
- Idempotensi: kalau ada risiko job dikirim dobel, tambahkan guard (mis. deduplicate key di
properties
). - Retensi: atur
activitylog.php
→delete_records_older_than_days
via laravel-activitylog (kalau Anda pakai fitur pruners). - Privacy: jangan simpan
attributes
yang sensitif; filter terlebih dulu:Arr::except($snapshot, ['password', 'token'])
.
Kalau mau, saya bisa siapkan trait helper DispatchesActivityLog
supaya pemanggilan LogActivityJob
jadi one-liner dan konsisten di seluruh Resource/Pages.
iya
Thought for 7s
The user confirmed with "iya" that they'd like the trait helper and related integrations. I'll keep things concise, providing code snippets for the trait, service class, and middleware for request context. I'll also include batch logging and throttling, ensuring the language is Indonesian and maintaining brevity.
Done
Mantap. Kita bikin trait helper supaya nge-dispatch activity log jadi one-liner di mana pun (Filament Resource/Pages/Actions, controller, service), plus konteks request (IP, user-agent, route) otomatis ikut.
- Helper konteks request (opsional tapi praktis) =================================================
app/Support/RequestContext.php
<?php
namespace App\Support;
class RequestContext
{
public static function capture(array $extra = []): array
{
$req = request();
return array_filter(array_merge([
'ip' => optional($req)->ip(),
'user_agent' => optional($req)->userAgent(),
'route' => optional($req)->path(),
'url' => optional($req)->fullUrl(),
'method' => optional($req)->method(),
], $extra), fn ($v) => !is_null($v));
}
}
- Trait:
DispatchesActivityLog
=================================
app/Traits/DispatchesActivityLog.php
<?php
namespace App\Traits;
use App\Jobs\LogActivityJob;
use App\Support\RequestContext;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
trait DispatchesActivityLog
{
/**
* Kirim job pencatatan activity ke queue.
*
* @param Model|string $subject Model instance ATAU class-string<Model>
* @param string|null $description
* @param string|null $event contoh: created|updated|deleted|restored|force_deleted
* @param array $properties data tambahan/snapshot
* @param string|null $logName nama log (default null -> default log)
* @param array|null $options ['queue'=>'logging','sanitize'=>['password','token']]
*/
public static function dispatchActivity(
Model|string $subject,
?string $description,
?string $event = null,
array $properties = [],
?string $logName = null,
?array $options = null
): void {
$options = array_merge(['queue' => 'logging', 'sanitize' => []], $options ?? []);
$subjectId = $subject instanceof Model ? $subject->getKey() : null;
$subjectCl = $subject instanceof Model ? get_class($subject) : $subject;
// Gabung properties + context request
$props = array_merge(
RequestContext::capture(),
$properties
);
// Sanitasi sederhana jika perlu
if (!empty($options['sanitize'])) {
$props = Arr::except($props, $options['sanitize']);
if (isset($props['attributes']) && is_array($props['attributes'])) {
$props['attributes'] = Arr::except($props['attributes'], $options['sanitize']);
}
if (isset($props['new']) && is_array($props['new'])) {
$props['new'] = Arr::except($props['new'], $options['sanitize']);
}
if (isset($props['old']) && is_array($props['old'])) {
$props['old'] = Arr::except($props['old'], $options['sanitize']);
}
}
// Kirim job
$job = new LogActivityJob(
subjectClass: $subjectCl,
subjectId: $subjectId,
causerId: auth()->id(),
description: $description ?? '-',
event: $event,
properties: $props,
logName: $logName
);
if (!empty($options['queue'])) {
$job->onQueue($options['queue']);
}
dispatch($job);
}
}
Catatan:
- Bisa dipakai static di Resource/Page (
self::dispatchActivity(...)
) atau di class biasa (DispatchesActivityLog
sebagai trait instance method juga berfungsi).- Ada opsi
sanitize
untuk nge-drop field sensitif (mis.['password','remember_token']
).
- Pakai di Filament Resource (Delete/Edit/Create) ==================================================
app/Filament/Resources/YourModelResource.php
use App\Traits\DispatchesActivityLog;
use Filament\Tables;
use Filament\Tables\Actions\{DeleteAction, EditAction, CreateAction};
class YourModelResource extends Resource
{
use DispatchesActivityLog;
public static function table(\Filament\Tables\Table $table): \Filament\Tables\Table
{
return $table
->columns([
// ...
])
->actions([
DeleteAction::make()
->after(function ($record) {
// snapshot sebelum objek hilang dari memori
$snapshot = $record->toArray();
self::dispatchActivity(
subject: $record,
description: 'Data dihapus melalui Filament',
event: 'deleted',
properties: ['attributes' => $snapshot],
logName: 'default',
options: ['queue' => 'logging', 'sanitize' => ['password','token']]
);
}),
EditAction::make()
->after(function ($record, array $data) {
self::dispatchActivity(
subject: $record,
description: 'Data diperbarui melalui Filament',
event: 'updated',
properties: ['new' => $data],
logName: 'default'
);
}),
CreateAction::make()
->after(function ($record, array $data) {
self::dispatchActivity(
subject: $record,
description: 'Data dibuat melalui Filament',
event: 'created',
properties: ['new' => $data],
logName: 'default'
);
}),
]);
}
}
Untuk hard delete (force delete di tempat lain), tinggal kirim pakai class name + ID:
self::dispatchActivity(
subject: \App\Models\YourModel::class,
description: 'Data dihapus permanen',
event: 'force_deleted',
properties: ['_reason' => 'maintenance cleanup', '_subject_id' => $id],
);
- Bonus (opsional): deduplikasi Job ====================================
Kalau khawatir double dispatch, Anda bisa ubah LogActivityJob
agar unik untuk beberapa detik:
use Illuminate\Contracts\Queue\ShouldBeUnique;
class LogActivityJob implements ShouldQueue, ShouldBeUnique
{
public $uniqueFor = 10; // detik
public function uniqueId(): string
{
return sha1(json_encode([
$this->subjectClass, $this->subjectId, $this->causerId,
$this->description, $this->event, $this->properties, $this->logName,
]));
}
}
- Ringkas setup Queue ======================
.env
QUEUE_CONNECTION=database
- Migrasi & worker
php artisan queue:table && php artisan migrate php artisan queue:work --queue=logging,default
Kalau mau, saya bisa tambahkan macro helper (mis. activity_dispatch(...)
) via Service Provider supaya bisa dipanggil global tanpa trait. Mau dibuatkan juga?
jika handle data banyak atau bulk action
Thought for 16s
I’m figuring out how to handle bulk delete actions in Filament v4, specifically focusing on efficient activity logging with queue job batching. I need to demonstrate options like per-record dispatch, chunked dispatch, and Bus::batch. Performance considerations will involve avoiding heavy toArray calls, using eager loading, and rate limiting. I'll provide code examples for DeleteBulkAction::make()->after() and handle progress and failure with Bus::batch, keeping transaction management in mind.
Done
Sip, untuk bulk action (mis. DeleteBulkAction
) di Filament v4 tanpa observer, kamu punya 3 pola aman & efisien:
- Dispatch per-record (paling sederhana)
- Dispatch satu job untuk sekumpulan record (ringan di dispatcher)
- Gunakan
Bus::batch()
supaya eksekusi paralel & ada toleransi error
Di bawah ini aku kasih implementasi siap tempel untuk ketiganya. Pilih salah satu sesuai beban data & kebutuhan monitoring.
- Prasyarat singkat ====================
- Pastikan kamu sudah punya
LogActivityJob
(dari langkah sebelumnya). - Worker jalan:
php artisan queue:work --queue=logging,default
- Filament: DeleteBulkAction → dispatch per-record (simpel) ================================================================
use Filament\Tables;
use Filament\Tables\Actions\DeleteBulkAction;
use Illuminate\Support\Collection;
use App\Traits\DispatchesActivityLog; // trait helper yang kita buat
public static function table(\Filament\Tables\Table $table): \Filament\Tables\Table
{
return $table
->columns([
// ...
])
->bulkActions([
DeleteBulkAction::make()
// Simpan snapshot SEBELUM delete agar data masih ada
->before(function (Collection $records) {
// inject snapshot ringan ke container action (opsional)
// atau, paling mudah: simpan ke cache sementara
cache()->put(
self::class.':bulk-delete-snapshots:'.auth()->id(),
$records->mapWithKeys(fn ($r) => [$r->getKey() => $r->toArray()])->all(),
now()->addMinutes(5)
);
})
->after(function (Collection $records) {
$snapshots = cache()->pull(self::class.':bulk-delete-snapshots:'.auth()->id(), []);
$records->each(function ($record) use ($snapshots) {
$snapshot = $snapshots[$record->getKey()] ?? $record->toArray(); // fallback
// one-liner dari trait
self::dispatchActivity(
subject: $record,
description: 'Data dihapus (bulk) melalui Filament',
event: 'deleted',
properties: ['attributes' => $snapshot],
logName: 'default',
options: ['queue' => 'logging', 'sanitize' => ['password','token']]
);
});
}),
]);
}
Kapan cocok: jumlah record tidak besar (puluhan—ratusan) dan kamu ingin log tetap granular per baris.
- Filament: DeleteBulkAction → satu job untuk sekumpulan record ====================================================================
Lebih hemat overhead dispatch (satu job), job yang memproses chunk di worker.
// app/Jobs/BulkLogActivityJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class BulkLogActivityJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** @var array<int, array{class:string,id:int|null,description:string,event:?string,properties:array,logName:?string,causerId:?int}> */
public array $items;
public function __construct(array $items)
{
$this->items = $items;
$this->onQueue('logging');
}
public function handle(): void
{
foreach (array_chunk($this->items, 200) as $chunk) {
foreach ($chunk as $item) {
activity($item['logName'] ?? null)
->event($item['event'] ?? null)
->causedBy(optional(\App\Models\User::find($item['causerId'])) ?? null)
->tap(function ($logger) use ($item) {
// coba ambil subject; jika soft-deletes, withTrashed
$builder = ($item['class'])::query();
if (in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($item['class']))) {
$builder->withTrashed();
}
$subject = $item['id'] ? $builder->find($item['id']) : null;
if ($subject) {
$logger->performedOn($subject);
} else {
$props = $item['properties'] ?? [];
$props['_subject_type'] = $item['class'];
$props['_subject_id'] = $item['id'];
$logger->withProperties($props);
return;
}
$logger->withProperties($item['properties'] ?? []);
})
->log($item['description'] ?? '-');
}
}
}
}
use Filament\Tables\Actions\DeleteBulkAction;
use Illuminate\Support\Collection;
use App\Jobs\BulkLogActivityJob;
use App\Support\RequestContext;
DeleteBulkAction::make()
->before(function (Collection $records) {
cache()->put(
self::class.':bulk-delete-snapshots:'.auth()->id(),
$records->mapWithKeys(fn ($r) => [$r->getKey() => $r->toArray()])->all(),
now()->addMinutes(5)
);
})
->after(function (Collection $records) {
$snapshots = cache()->pull(self::class.':bulk-delete-snapshots:'.auth()->id(), []);
$ctx = RequestContext::capture();
$items = $records->map(function ($r) use ($snapshots, $ctx) {
return [
'class' => get_class($r),
'id' => $r->getKey(),
'causerId' => auth()->id(),
'description' => 'Data dihapus (bulk) melalui Filament',
'event' => 'deleted',
'properties' => array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
'logName' => 'default',
];
})->values()->all();
BulkLogActivityJob::dispatch($items);
});
Kapan cocok: ratusan—ribuan record; ingin minim overhead dispatch dan tetap satu titik eksekusi.
- Filament: DeleteBulkAction →
Bus::batch()
(monitoring & retry individual) ==================================================================================
Kamu dapat visibilitas progress & kegagalan tiap item.
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
use Filament\Tables\Actions\DeleteBulkAction;
use Illuminate\Support\Collection;
use App\Jobs\LogActivityJob;
use App\Support\RequestContext;
DeleteBulkAction::make()
->before(function (Collection $records) {
cache()->put(
self::class.':bulk-delete-snapshots:'.auth()->id(),
$records->mapWithKeys(fn ($r) => [$r->getKey() => $r->toArray()])->all(),
now()->addMinutes(5)
);
})
->after(function (Collection $records) {
$snapshots = cache()->pull(self::class.':bulk-delete-snapshots:'.auth()->id(), []);
$ctx = RequestContext::capture();
$jobs = $records->map(function ($r) use ($snapshots, $ctx) {
return new LogActivityJob(
subjectClass: get_class($r),
subjectId: $r->getKey(),
causerId: auth()->id(),
description: 'Data dihapus (bulk) melalui Filament',
event: 'deleted',
properties: array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
logName: 'default'
);
})->values()->all();
Bus::batch($jobs)
->onQueue('logging')
->name('bulk-delete-activitylog:'.class_basename(static::class))
->allowFailures() // biarkan sebagian gagal tanpa menggagalkan batch
->dispatch();
});
Kapan cocok: ratusan—puluhan ribu record; kamu ingin progress, partial failures, & kemudahan observabilitas.
- Ambil snapshot sebelum delete (
->before
) supaya data masih lengkap.
Kalau data besar, simpan field minimal saja:$records->mapWithKeys(fn ($r) => [$r->getKey() => $r->only(['id','kode','nama'])]);
- Soft delete vs hard delete sudah ditangani: job mencoba
withTrashed()
, kalau gagal, subjek dicatat diproperties
. - Chunking: di
BulkLogActivityJob
sudah adaarray_chunk(…, 200)
. Ubah sesuai kapasitas. - Sanitasi: hindari menyimpan data sensitif (
password
,token
, dsb). - Transaksi: kalau operasi delete kamu dibungkus transaksi manual, dispatch job setelah commit:
DB::afterCommit(fn () => Bus::batch($jobs)->dispatch());
- Kustom queue: jalankan worker khusus logging:
php artisan queue:work --queue=logging --tries=3
Kalau kamu juga pakai Bulk Restore atau Bulk Force Delete, tinggal duplikasi pola yang sama:
event: 'restored'
untuk restore,event: 'force_deleted'
untuk hard delete,- dan pada force delete, kirim
subjectClass
+id
kalau model sudah tidak tersedia.
Butuh versi yang di-wrap jadi helper Bulk (satu fungsi util yang kamu panggil dari berbagai Resource)? Aku bisa rapikan jadi BulkActivityLogger
service juga.
iya silakan
Siap. Berikut service helper yang merapikan seluruh pola bulk logging (per-record, satu job, atau Bus::batch
) + util untuk snapshot sebelum delete. Tujuannya: cukup panggil 1–2 baris di tiap Bulk Action.
- Service:
BulkActivityLogger
================================
app/Services/BulkActivityLogger.php
<?php
namespace App\Services;
use App\Jobs\LogActivityJob;
use App\Jobs\BulkLogActivityJob;
use App\Support\RequestContext;
use Illuminate\Bus\Batch;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
class BulkActivityLogger
{
/**
* Simpan snapshot ringan ke cache sebelum operasi bulk (mis. delete).
* Pakai di callback ->before() Filament BulkAction.
*
* @param iterable $records Eloquent\Collection|array
* @param string $cacheKey unik per user/aksi
* @param array|null $only field yang disimpan (null = toArray())
* @param int $ttlMinutes TTL cache
*/
public static function cacheSnapshots(iterable $records, string $cacheKey, ?array $only = null, int $ttlMinutes = 10): void
{
$map = collect($records)->mapWithKeys(function ($r) use ($only) {
/** @var \Illuminate\Database\Eloquent\Model $r */
$data = $only ? $r->only($only) : $r->toArray();
return [$r->getKey() => $data];
})->all();
Cache::put($cacheKey, $map, now()->addMinutes($ttlMinutes));
}
/**
* Ambil & hapus snapshot dari cache (sekali pakai).
*/
public static function pullSnapshots(string $cacheKey, array $default = []): array
{
return Cache::pull($cacheKey, $default);
}
/**
* Dispatch logging per-record (granular).
*
* @param iterable $records
* @param string $description
* @param string|null $event
* @param array $snapshots [id => snapshotArray]
* @param string|null $logName
* @param array $options ['queue'=>'logging','sanitize'=>[...] ]
*/
public static function dispatchPerRecord(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging', 'sanitize' => ['password','token']]
): void {
$ctx = RequestContext::capture();
collect($records)->each(function ($r) use ($description, $event, $snapshots, $logName, $ctx, $options) {
$snapshot = $snapshots[$r->getKey()] ?? [];
// Kirim job satu per record
$job = new LogActivityJob(
subjectClass: get_class($r),
subjectId: $r->getKey(),
causerId: auth()->id(),
description: $description,
event: $event,
properties: array_merge($ctx, ['attributes' => $snapshot]),
logName: $logName,
);
if (!empty($options['queue'])) {
$job->onQueue($options['queue']);
}
dispatch($job);
});
}
/**
* Dispatch satu job untuk sekumpulan record (ringan di dispatcher).
*/
public static function dispatchSingleJob(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging']
): void {
$ctx = RequestContext::capture();
$items = collect($records)->map(function ($r) use ($description, $event, $snapshots, $logName, $ctx) {
return [
'class' => get_class($r),
'id' => $r->getKey(),
'causerId' => auth()->id(),
'description' => $description,
'event' => $event,
'properties' => array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
'logName' => $logName,
];
})->values()->all();
$job = new BulkLogActivityJob($items);
if (!empty($options['queue'])) {
$job->onQueue($options['queue']);
}
dispatch($job);
}
/**
* Dispatch sebagai batch (monitor progress & partial failures).
*/
public static function dispatchAsBatch(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging', 'name' => null, 'allowFailures' => true]
): Batch {
$ctx = RequestContext::capture();
$jobs = collect($records)->map(function ($r) use ($description, $event, $snapshots, $logName, $ctx) {
return (new LogActivityJob(
subjectClass: get_class($r),
subjectId: $r->getKey(),
causerId: auth()->id(),
description: $description,
event: $event,
properties: array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
logName: $logName,
))->onQueue($options['queue'] ?? 'logging');
})->values()->all();
$batch = Bus::batch($jobs)
->onQueue($options['queue'] ?? 'logging')
->name($options['name'] ?? ('bulk-activitylog-'.now()->format('YmdHis')))
->when($options['allowFailures'] ?? true, fn ($b) => $b->allowFailures())
->dispatch();
return $batch;
}
}
- Contoh pakai di Filament (DeleteBulkAction) ==============================================
use Filament\Tables\Actions\DeleteBulkAction;
use App\Services\BulkActivityLogger;
DeleteBulkAction::make()
->before(function (\Illuminate\Support\Collection $records) {
// Simpan snapshot ringan sebelum delete
$cacheKey = static::class.':bulk-delete-snapshots:'.auth()->id();
BulkActivityLogger::cacheSnapshots($records, $cacheKey, only: ['id','kode','nama']); // pilih field penting saja
})
->after(function (\Illuminate\Support\Collection $records) {
$cacheKey = static::class.':bulk-delete-snapshots:'.auth()->id();
$snapshots = BulkActivityLogger::pullSnapshots($cacheKey, []);
// PILIH SALAH SATU pola di bawah:
// (A) Granular per-record
// BulkActivityLogger::dispatchPerRecord($records, 'Data dihapus (bulk) via Filament', 'deleted', $snapshots);
// (B) Satu job untuk semua
// BulkActivityLogger::dispatchSingleJob($records, 'Data dihapus (bulk) via Filament', 'deleted', $snapshots);
// (C) Batch (punya progress & partial failure)
BulkActivityLogger::dispatchAsBatch(
$records,
description: 'Data dihapus (bulk) via Filament',
event: 'deleted',
snapshots: $snapshots,
logName: 'default',
options: ['queue' => 'logging', 'name' => 'bulk-delete-'.class_basename(static::class)]
);
});
Ganti
only: ['id','kode','nama']
sesuai kebutuhan. Hindari field sensitif.
- Tambahan: Bulk Restore & Force Delete ========================================
// Restore bulk
BulkActivityLogger::dispatchPerRecord($records, 'Data dipulihkan (bulk) via Filament', 'restored', $snapshots);
// Force delete bulk (hard delete):
// Kalau subject sudah hilang, job akan menaruh _subject_type & _subject_id di properties.
BulkActivityLogger::dispatchAsBatch(
$records,
'Data dihapus permanen (bulk) via Filament',
'force_deleted',
$snapshots
);
- Best Practices Singkat =========================
- Snapshot minimal: pilih field penting dengan
only
agar cache & log ringan. - Transaksi: bila delete dibungkus transaksi manual, panggil dispatcher setelah commit (
DB::afterCommit()
). - Queue khusus: jalankan worker terpisah untuk
logging
:php artisan queue:work --queue=logging --tries=3
- Sanitasi: filter
password
,token
, dsb di snapshot sebelum dikirim. - Observabilitas: untuk beban besar, pilih batch agar bisa pantau progress & failure.
Kalau kamu mau, saya bisa tambahkan Facade (BulkActivityLog
) atau binding di Service Provider supaya pemanggilan jadi lebih singkat.
mgkn bisa kasih saya versi lengkapnya step by step nya
Thought for 30s
I need to create a full guide for queued activity logging in Laravel Filament v4, covering default delete actions, bulk actions, job classes, and specific hooks. I'll include step-by-step instructions, configuring the necessary packages and templates, testing steps, and privacy considerations while maintaining performance optimization tips. I'll keep it concise and detailed with code snippets but avoid unnecessary fluff.
I need to give a detailed, step-by-step guide in Bahasa Indonesia for implementing queued activity logging in Filament v4. I’ll structure the guide with file paths, include options for both per-record and batch logging, and recommend a default choice. I'll cover integration for Filament’s BulkAction and Model actions, ensure proper namespaces, and highlight potential issues like callback signatures in DeleteAction and DeleteBulkAction.
I’ll provide a clear step-by-step process, including commands for installing the necessary package, publishing the config, and setting up the activity_log table. I’ll cover queue setup with command-line instructions, and also include troubleshooting, privacy, and security details. I’ll stay concise and provide Filament resource examples without extra fluff. A small test scenario for verification will help too.
Done
Siap—ini versi lengkap & runut step-by-step untuk menambahkan activity log via queue pada Filament v4 (termasuk single delete & bulk action) tanpa observer. Kamu bisa copy–paste per langkah.
- Laravel 10/11 + Filament v4 sudah terpasang.
- (Jika belum) pasang Spatie Activitylog:
composer require spatie/laravel-activitylog
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-config"
php artisan migrate
Pilih koneksi (contoh: database).
.env
QUEUE_CONNECTION=database
Buat tabel antrian & failed jobs:
php artisan queue:table
php artisan queue:failed-table
php artisan migrate
Jalankan worker (antrian khusus logging + default):
php artisan queue:work --queue=logging,default --tries=3
Bisa pakai Redis/SQS juga—cukup ubah
QUEUE_CONNECTION
dan worker-nya.
app/Support/RequestContext.php
<?php
namespace App\Support;
class RequestContext
{
public static function capture(array $extra = []): array
{
$req = request();
return array_filter(array_merge([
'ip' => optional($req)->ip(),
'user_agent' => optional($req)->userAgent(),
'url' => optional($req)->fullUrl(),
'route' => optional($req)->path(),
'method' => optional($req)->method(),
], $extra), fn ($v) => !is_null($v));
}
}
app/Jobs/LogActivityJob.php
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Database\Eloquent\SoftDeletes;
class LogActivityJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public string $subjectClass;
public ?int $subjectId;
public ?int $causerId;
public string $description;
public ?string $event;
public array $properties;
public ?string $logName;
public function __construct(
string $subjectClass,
?int $subjectId,
?int $causerId,
string $description,
?string $event = null,
array $properties = [],
?string $logName = null
) {
$this->subjectClass = $subjectClass;
$this->subjectId = $subjectId;
$this->causerId = $causerId;
$this->description = $description;
$this->event = $event;
$this->properties = $properties;
$this->logName = $logName;
$this->onQueue('logging');
}
public function handle(): void
{
$causer = $this->causerId ? User::find($this->causerId) : null;
$subject = null;
if ($this->subjectId) {
$builder = ($this->subjectClass)::query();
// dukung SoftDeletes (termasuk yang sudah di-soft delete)
if (in_array(SoftDeletes::class, class_uses_recursive($this->subjectClass))) {
$builder->withTrashed();
}
$subject = $builder->find($this->subjectId);
}
$logger = activity($this->logName)
->event($this->event)
->withProperties($this->properties);
if ($causer) {
$logger->causedBy($causer);
}
if ($subject instanceof Model) {
$logger->performedOn($subject)->log($this->description);
return;
}
// Hard delete: subject tidak ditemukan — simpan identitasnya di properties
$logger->withProperties(array_merge($this->properties, [
'_subject_type' => $this->subjectClass,
'_subject_id' => $this->subjectId,
'_note' => 'Subject tidak ditemukan saat menulis log (kemungkinan hard delete).',
]))->log($this->description);
}
}
app/Jobs/BulkLogActivityJob.php
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* @property array<int, array{
* class:string,
* id:int|null,
* causerId:int|null,
* description:string,
* event:?string,
* properties:array,
* logName:?string
* }> $items
*/
class BulkLogActivityJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public array $items;
public function __construct(array $items)
{
$this->items = $items;
$this->onQueue('logging');
}
public function handle(): void
{
foreach (array_chunk($this->items, 200) as $chunk) {
foreach ($chunk as $item) {
$logger = activity($item['logName'] ?? null)
->event($item['event'] ?? null)
->withProperties($item['properties'] ?? []);
$causer = $item['causerId'] ? User::find($item['causerId']) : null;
if ($causer) {
$logger->causedBy($causer);
}
$subject = null;
if (!empty($item['id'])) {
$builder = ($item['class'])::query();
if (in_array(SoftDeletes::class, class_uses_recursive($item['class']))) {
$builder->withTrashed();
}
$subject = $builder->find($item['id']);
}
if ($subject instanceof Model) {
$logger->performedOn($subject)->log($item['description'] ?? '-');
} else {
$logger->withProperties(array_merge(($item['properties'] ?? []), [
'_subject_type' => $item['class'],
'_subject_id' => $item['id'] ?? null,
'_note' => 'Subject tidak ditemukan saat menulis log (kemungkinan hard delete).',
]))->log($item['description'] ?? '-');
}
}
}
}
}
app/Traits/DispatchesActivityLog.php
<?php
namespace App\Traits;
use App\Jobs\LogActivityJob;
use App\Support\RequestContext;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
trait DispatchesActivityLog
{
/**
* @param Model|string $subject Model instance atau class-string<Model>
* @param string|null $description
* @param string|null $event
* @param array $properties
* @param string|null $logName
* @param array|null $options ['queue'=>'logging','sanitize'=>['password','token']]
*/
public static function dispatchActivity(
Model|string $subject,
?string $description,
?string $event = null,
array $properties = [],
?string $logName = null,
?array $options = null
): void {
$options = array_merge(['queue' => 'logging', 'sanitize' => []], $options ?? []);
$subjectId = $subject instanceof Model ? $subject->getKey() : null;
$subjectCl = $subject instanceof Model ? get_class($subject) : $subject;
// Tambahkan konteks request
$props = array_merge(RequestContext::capture(), $properties);
// Sanitasi field sensitif
if (!empty($options['sanitize'])) {
$props = Arr::except($props, $options['sanitize']);
foreach (['attributes','new','old'] as $bag) {
if (isset($props[$bag]) && is_array($props[$bag])) {
$props[$bag] = Arr::except($props[$bag], $options['sanitize']);
}
}
}
$job = new LogActivityJob(
subjectClass: $subjectCl,
subjectId: $subjectId,
causerId: auth()->id(),
description: $description ?? '-',
event: $event,
properties: $props,
logName: $logName
);
if (!empty($options['queue'])) {
$job->onQueue($options['queue']);
}
dispatch($job);
}
}
app/Services/BulkActivityLogger.php
<?php
namespace App\Services;
use App\Jobs\LogActivityJob;
use App\Jobs\BulkLogActivityJob;
use App\Support\RequestContext;
use Illuminate\Bus\Batch;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
class BulkActivityLogger
{
public static function cacheSnapshots(iterable $records, string $cacheKey, ?array $only = null, int $ttlMinutes = 10): void
{
$map = collect($records)->mapWithKeys(function ($r) use ($only) {
$data = $only ? $r->only($only) : $r->toArray();
return [$r->getKey() => $data];
})->all();
Cache::put($cacheKey, $map, now()->addMinutes($ttlMinutes));
}
public static function pullSnapshots(string $cacheKey, array $default = []): array
{
return Cache::pull($cacheKey, $default);
}
/** Per-record jobs (granular) */
public static function dispatchPerRecord(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging']
): void {
$ctx = RequestContext::capture();
collect($records)->each(function ($r) use ($description, $event, $snapshots, $logName, $ctx, $options) {
$job = (new LogActivityJob(
subjectClass: get_class($r),
subjectId: $r->getKey(),
causerId: auth()->id(),
description: $description,
event: $event,
properties: array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
logName: $logName
))->onQueue($options['queue'] ?? 'logging');
dispatch($job);
});
}
/** Satu job untuk sekumpulan record (hemat dispatcher) */
public static function dispatchSingleJob(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging']
): void {
$ctx = RequestContext::capture();
$items = collect($records)->map(function ($r) use ($description, $event, $snapshots, $logName, $ctx) {
return [
'class' => get_class($r),
'id' => $r->getKey(),
'causerId' => auth()->id(),
'description' => $description,
'event' => $event,
'properties' => array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
'logName' => $logName,
];
})->values()->all();
dispatch((new BulkLogActivityJob($items))->onQueue($options['queue'] ?? 'logging'));
}
/** Batch (progress & partial failures) */
public static function dispatchAsBatch(
iterable $records,
string $description,
?string $event = null,
array $snapshots = [],
?string $logName = 'default',
array $options = ['queue' => 'logging', 'name' => null, 'allowFailures' => true]
): Batch {
$ctx = RequestContext::capture();
$jobs = collect($records)->map(function ($r) use ($description, $event, $snapshots, $logName, $ctx, $options) {
return (new LogActivityJob(
subjectClass: get_class($r),
subjectId: $r->getKey(),
causerId: auth()->id(),
description: $description,
event: $event,
properties: array_merge($ctx, ['attributes' => $snapshots[$r->getKey()] ?? []]),
logName: $logName
))->onQueue($options['queue'] ?? 'logging');
})->values()->all();
return Bus::batch($jobs)
->onQueue($options['queue'] ?? 'logging')
->name($options['name'] ?? ('bulk-activitylog-'.now()->format('YmdHis')))
->when($options['allowFailures'] ?? true, fn ($b) => $b->allowFailures())
->dispatch();
}
}
Di YourModelResource
:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\YourModelResource\Pages;
use App\Models\YourModel;
use App\Traits\DispatchesActivityLog;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\{DeleteAction, EditAction, CreateAction};
class YourModelResource extends Resource
{
use DispatchesActivityLog;
protected static ?string $model = YourModel::class;
public static function table(Tables\Table $table): Tables\Table
{
return $table
->columns([
// kolom kamu...
])
->actions([
DeleteAction::make()
->after(function ($record) {
// snapshot sebelum objek lenyap dari request
$snapshot = $record->toArray();
self::dispatchActivity(
subject: $record,
description: 'Data dihapus melalui Filament',
event: 'deleted',
properties: ['attributes' => $snapshot],
logName: 'default',
options: ['queue' => 'logging', 'sanitize' => ['password','token']]
);
}),
EditAction::make()
->after(function ($record, array $data) {
self::dispatchActivity(
subject: $record,
description: 'Data diperbarui melalui Filament',
event: 'updated',
properties: ['new' => $data],
logName: 'default'
);
}),
CreateAction::make()
->after(function ($record, array $data) {
self::dispatchActivity(
subject: $record,
description: 'Data dibuat melalui Filament',
event: 'created',
properties: ['new' => $data],
logName: 'default'
);
}),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListYourModels::route('/'),
// lainnya...
];
}
}
Hard delete (force delete) di tempat lain? Pakai:
self::dispatchActivity( subject: \App\Models\YourModel::class, // class string description: 'Data dihapus permanen', event: 'force_deleted', properties: ['_subject_id' => $id] );
Contoh paling praktis: pakai batch supaya ada toleransi error.
use Filament\Tables\Actions\DeleteBulkAction;
use App\Services\BulkActivityLogger;
// ...
->bulkActions([
DeleteBulkAction::make()
->before(function (\Illuminate\Support\Collection $records) {
// simpan snapshot ringan sebelum delete
$cacheKey = static::class.':bulk-delete-snapshots:'.auth()->id();
BulkActivityLogger::cacheSnapshots($records, $cacheKey, only: ['id','kode','nama']);
})
->after(function (\Illuminate\Support\Collection $records) {
$cacheKey = static::class.':bulk-delete-snapshots:'.auth()->id();
$snapshots = BulkActivityLogger::pullSnapshots($cacheKey, []);
// PILIH SATU pola:
// A) granular per-record:
// BulkActivityLogger::dispatchPerRecord($records, 'Data dihapus (bulk) via Filament', 'deleted', $snapshots);
// B) satu job untuk semua:
// BulkActivityLogger::dispatchSingleJob($records, 'Data dihapus (bulk) via Filament', 'deleted', $snapshots);
// C) batch (disarankan untuk ratusan+):
\Illuminate\Support\Facades\DB::afterCommit(function () use ($records, $snapshots) {
BulkActivityLogger::dispatchAsBatch(
$records,
description: 'Data dihapus (bulk) via Filament',
event: 'deleted',
snapshots: $snapshots,
logName: 'default',
options: ['queue' => 'logging', 'name' => 'bulk-delete-'.class_basename(static::class)]
);
});
}),
])
Kenapa
DB::afterCommit
? Kalau operasi delete-mu memakai transaksi, kita pastikan job dikirim setelah commit, sehingga snapshot & ID konsisten.
Ubah config/activitylog.php
sesuai kebutuhan:
- Log name default, event names, dsb.
- Pruning: hapus otomatis log lama (mis. 90 hari):
'default_log_name' => 'default', 'delete_records_older_than_days' => 90,
- Privacy: pastikan tidak menyimpan field sensitif di
properties
(gunakansanitize
atauonly([...])
saat snapshot).
- Jalankan worker:
php artisan queue:work --queue=logging,default
- Lakukan Create/Edit/Delete atau Bulk Delete di Filament.
- Cek tabel
activity_log
:
SELECT id, log_name, description, event, causer_id, subject_type, subject_id, properties, created_at
FROM activity_log
ORDER BY id DESC
LIMIT 20;
Kamu harus melihat entri:
description
: “Data dibuat/diperbarui/dihapus…”event
:created|updated|deleted|force_deleted|restored
properties
: memuatattributes
(snapshot),ip
,user_agent
, dll.
- Snapshot minimal: pakai
only(['id','kode','nama'])
agar ringan. - Chunk: di
BulkLogActivityJob
default 200; sesuaikan beban. - Queue terpisah: jalankan worker khusus
logging
untuk isolasi. - Retry:
--tries=3
+ failed jobs table sudah siap. - Observabilitas: Jika pakai Redis, pertimbangkan Laravel Horizon.
Kalau mau, aku bisa ringkas semua ini ke sebuah package internal (Service Provider + Facade) biar instalasinya tinggal composer require
+ publish config. Mau?
Powered by ChatGPT Exporter