Awwwards
Queuing Up Your Activity Logger's Hero Image

Queuing Up Your Activity Logger

As you probably know, Spatie consistently pumps out some really great open-source packages for Laravel. We're using a handful, notably Backup, Newsletter, and of course ActivityLog.

A really great feature of ActivityLog is the ability to log model events. When you enable this and configure the bits of information you want to capture in the log, you don't have to worry about the actual logging action. It will automatically capture the differences and then associate the log event to the model being acted upon (i.e. the $subject), and the model doing the activity (i.e. the $causer). This is a fantastic feature and is much better than what I was doing before. However, there are still some limitations to using the package.

For instance, when you install it, you add the LogsActivity trait to each of the models you want to monitor. This enables you to recall the activity logged about this model by simply invoking $model->activity(). Then, you add the CausesActivity trait to your Users, so you can call $model->activity() and see what activity has been caused by this model. Normally, this is totally fine.

Creating More Work for Myself

But what if you have two different authenticated models? Our website has Users and Customers, so we need Users and Customers to be both the cause and the subject of activity. Luckily, Freek decoupled the idea of being the subject of activity and being able to detect changes about the model. So, instead of using the LogsActivity trait, I use the DetectsChanges trait on User and Customer.

Admittedly I am creating more work for myself, but I'm getting a much more descriptive activity log for both Users, Customers, and any other model in my app that might need to both act and be acted upon.

So what does this look like? Let's say I need to update a Customer. If you use the package as described in the documentation, this would be a very simple operation.

$customer = $this->update($request->all());

The logging would happen behind the scenes and you would be on your way. The way I'm describing, however, would look like this:

$oldAttributes = Customer::logChanges($customer);
$customer = $this->update($request->all());
$updatedAttributes = Customer::logChanges($customer);
activity()->causedBy(Auth::user())
          ->performedOn($customer)
          ->withProperties([
              'attributes' => $updatedAttributes, 
              'old' => $oldAttributes
          ])
          ->log('This Customer was updated');

First, you need to grab the "old-attributes"; then update the model, and then grab the "updated-attributes". That way you'll have a proper change log. This is much more verbose then the creators probably intended, but it allows me to do something like this:

$oldAttributes = Customer::logChanges($customer);
$customer = $this->update($request->all());
$updatedAttributes = Customer::logChanges($customer);
activity()->causedBy(Auth::guard('customer')->user())
          ->performedOn($customer)
          ->withProperties([
              'attributes' => $updatedAttributes, 
              'old' => $oldAttributes
          ])
          ->log('This Customer updated their information');

Now, I know that the information was changed by the Customer and not by a User.

Queue It Up

After setting this up, I noticed that this code snippet was getting repeated a lot in my various controllers. I also realized that now almost every action had to wait on the ActivityLogger to finish it's job. So, in order to prevent requests from depending on the Logger to finish, I wrote a universal job that would queue all the necessary information, with some reasonable defaults.

The first thing to look at is the constructor:

public function __construct(Model $causer = null, Model $subject, string $logName = 'default', array $properties = [], string $message = 'Look, I logged something.')
{
    $this->causer     = $causer;
    $this->subject    = $subject;
    $this->logName    = $logName;
    $this->properties = $properties;
    $this->message    = $message;
}

And then the handle() function:

public function handle()
{
    activity()->causedBy($this->getCauserToUse())
        ->performedOn($this->subject)
        ->inLog($this->getLogNameToUse())
        ->withProperties($this->properties)
        ->log($this->message);
}

If a $causer isn't passed to the job, I automatically try to resolve who might be causing the activity:

private function getCauserToUse()
{
    if(is_null($this->causer)) {
        if(auth()->user()) {
            return auth()->user();
        } elseif (auth('customer')->user()) {
            return auth('customer')->user();
        } else {
            throw new CouldNotLogActivity('Authenticated User not found.');
        }
    } else {
        return $this->causer;
    }
}

Note: If I add more authentication guards to the app I have to remember to come here and add them to this function.

I also try and determine which Log I want to write the activity to:

private function getLogNameToUse()
{
    if(method_exists($this->subject, 'getLogNameToUse')) {
        return $this->subject->getLogNameToUse();
    } else {
        return $this->logName;
    }
}

This is actually based on my own contribution to the package which allows you to specify a different log name for each model you're logging :D

With this Queue job in place, I can finally send the Log to the queue:

$oldAttributes = Customer::logChanges($customer);
$customer = $this->update($request->all());
$updatedAttributes = Customer::logChanges($customer);
dispatch(new LogActivity($customer, 
    $customer, 
    $customer->getLogNameToUse(), 
    ['attributes' => $updatedAttributes, 'old' => $oldAttributes], 
    ":causer.full_name updated their details"));

Thoughts

I think this is a neat little solution for logging different types of models. An issue has already been submitted to the package about models that need to be both $causer and $subject, but I imagine there is a pretty small audience requesting that kind of feature.

This approach does have its downsides. I lose out on the polymorphic relationship to Activity that the LogsActivity or CausesActivity traits enable. I could get around this by adding two functions activityCauser() and activitySubject() to my models that would recreate the polymorphic benefits. Or, it would be simple enough to write the queries as Activity::where('subject', $subject), and then build whatever report I might need.

All in all, this is a fantastic package that allowed me to easily extract the parts I needed and build something very useful for my specific needs.


Patrick Guevara's Profile Picture

Patrick Guevara


Chief Software Engineer

Patrick cofounded Metric Loop on the dream of building really great software with a clean, transparent approach. He lives in Austin, Texas with his wife Jess and their dog named Moose.