I’ve been playing around with complex queue items using Laravel Eloquent model classes not saved in the database. Before being serialized for a queue job, there are two situations where a model instance can get into this state.

  • A Model@make() / Model@setRelation() structure built up before Model@save() is called.
  • After Model@delete() is called.

The idea is fill the gap where Laravel doesn’t consider these objects eligible for queue processing. Please note this is really just a coding exercise. The former case is very rare. For post-delete queue actions, a SoftDeletes framework-ready solution is shown at the bottom of this post.

Queued jobs can throw ModelNotFoundException

The base Illuminate\Database\Eloquent\Model class implements contract QueueableEntity. This acts as a flag when it comes time to serialize objects for a queue. By default, the framework serializes models as just the class name and active record key. e.g.,

serialize(
    new Illuminate\Contracts\Database\ModelIdentifier(4495, 'App\PaymentMethod')
);

// "O:45:"Illuminate\Contracts\Database\ModelIdentifier":2:" .
// "{s:5:"class";i:4495;s:2:"id";s:17:"App\PaymentMethod";}"

Database row(s) can change after the item is queued so by the time a worker picks it up, it’s almost guaranteed to have the latest application snapshot of that model’s state.

For an unsaved payment method:

serialize(
    new Illuminate\Contracts\Database\ModelIdentifier(null, 'App\PaymentMethod')
);

// "O:45:"Illuminate\Contracts\Database\ModelIdentifier":2:" .
// "{s:5:"class";N;s:2:"id";s:17:"App\PaymentMethod";}"

unserialize() of the null model identifier is followed by an App\PaymentMethod database query that will throw ModelNotFoundException since there’s nothing to be found for a null key.

Sending mailable for a permanently deleted model

So let’s go through a potential solution for queued models already deleted.

Permanently deleting someone’s tokenized card from their user account, the controller method (ignoring request authorization) may look like this.

namespace App\Http\Controllers;

use App\PaymentMethod;
use App\Mail\PaymentMethodDeleted;

class PaymentMethodController extends Controller
{
    public function destroy(PaymentMethod $paymentMethod)
    {
        $paymentMethod->load('user')->delete();

        Mail::to($paymentMethod->user)
            ->queue(new PaymentMethodDeleted(
                $paymentMethod, request()->ip()
            ));

        return redirect()->to('/payment-methods');
    }
});

The PaymentMethod model class will implement a QueueablePayload contract. Queued jobs, mailables, notifications, etc. will handle that contract by using a custom trait.

belongsTo(User::class);
    }

    public function shouldQueueAsPayload()
    {
        return ! $this->exists;
    }
}

shouldQueueAsPayload() would be declared in a base App\Model class to indicate when the database primary key can’t be referenced. In Laravel 5.4, Eloquent sets the exists property to true after Model@create() and false after Model@delete() is called so I just leveraged that. (Note this behaviour changes in Laravel 5.5!)

The new QueueablePayload contract (also used as a serialization flag) is mostly covered by Eloquent’s Model class.

interface QueueablePayload
{
    public function shouldQueueAsPayload();

    public function getAttributes();

    public function getRelations();

    public function setRelations(array $relations);

    public function fill(array $attributes);
}

Instead of the mailable class pulling in framework trait Illuminate\Queue\SerializesModels, an app-namespace App\Queue\SerializesModels is used to serialize the key/value paired payload and eager-loaded relations.

namespace App\Mail;

use App\PaymentMethod;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use App\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class PaymentMethodDeleted extends Mailable
{
    use Queueable, SerializesModels;

    public $paymentMethod;
    public $ip;

    public function __construct($paymentMethod, $ip)
    {
        $this->paymentMethod = $paymentMethod;
        $this->ip = $ip;
    }

    public function build()
    {
        return $this->view('mail.payment-method-deleted');
    }
}

The meat of this implementation will override SerializesModels methods getSerializedPropertyValue() and getRestoredPropertyValue() to put the raw serialized model on the queue. If related eager-loaded models are still saved, only their ID is put in the payload string.

namespace App\Queue;

use Illuminate\Queue\SerializesModels as BaseSerializesModels;

trait SerializesModels
{
    use BaseSerializesModels {
        getSerializedPropertyValue as getSerializedById;
        getRestoredPropertyValue as getRestoredById;
    }

    protected function getSerializedPropertyValue($value)
    {
        if ($value instanceof QueueablePayload && $value->shouldQueueAsPayload()) {
            return new ModelPayload(
                get_class($value),
                $value->getAttributes(),
                array_map(function ($relation) {
                    return $this->getSerializedPropertyValue($relation);
                }, $value->getRelations())
            );
        }

        return $this->getSerializedById($value);
    }

    protected function getRestoredPropertyValue($value)
    {
        if ($value instanceof ModelPayload) {
            return (new $value->class)
                ->fill($value->attributes)
                ->setRelations(
                    array_map(function ($relation) {
                        return $this->getRestoredPropertyValue($relation);
                    }, $value->relations)
                );
        }

        return $this->getRestoredById($value);
    }
}

The Illuminate\Contracts\Database\ModelIdentifier class replacement put through PHP serialize() is just three properties.

class = $class;
        $this->attributes = $attributes;
        $this->relations = $relations;
    }
}

So despite a payment_methods row no longer existing, this email template can still be sent (with an eager-loaded user!)

Account – {{ $paymentMethod->user->name }}

{{ $paymentMethod->brand }} payment method ending in {{ $paymentMethod->last4 }}
was removed from your account. Request was made from IP address {{ $ip }}.

If you believe your account has been compromised,
please contact customer support at lol@suckedin.co.

There Be Danger

  • If eager-loaded relations are also unsaved, the raw PHP serialized string could get huge fast
  • With the QueueablePayload contract applied to all model classes, you run the risk of accidentally sending customer communications about stuff not yet saved.

Actual Solutions For Real World Humans

For the above example, the Laravel OOTB solution is simple:

  • Make the App\PaymentMethod model class use the SoftDeletes trait, adding a payment_methods.deleted_at nullable database column.
  • Viola, you don’t have to worry about the mailable throwing a ModelNotFoundException.
  • Permanently nuke yesterday’s deleted payment methods by scheduling a command to run everyday at 1AM. It will just execute a forceDelete() query.

    App\Payment::whereNotNull('deleted_at')
        ->where('deleted_at', '>=', Carbon::yesterday())
        ->where('deleted_at', '<', Carbon::today())
        ->forceDelete();