There are issues when you pass a Eloquent model with foreign relationships to a job class? Issues occurs when serialization and deserialization of the job payload before processing. Serialization process may not store foreign relationship constrains. As a result you will get different result when processing the queued job. Don’t worry if you does not understand anything by reading this paragraph. Everything will explain clearly later in this article.
<?php
namespace App\Jobs;
use App\Models\Author;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessFileData implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(Author $author)
{
//
}
.......
.......
}
In a practical environment you cannot pass a modal with its data to the queue storage service like Redis or Amazon SQS. Because those models may have huge number of data which may cause queue service to run out of storage very quickly when the number of jobs increased in the queue. To cope with this issue Laravel serialize and deserialize eloquent models with its relationships. Serialization reduce the job payload sent to the queue service.
What is Eloquent model serialization and deserialization?
Serialization
Lets see what is Serialization of Eloquent models before adding to queue service. Here eloquent models unique identifier which is the primary key or unique key to get data from the database and relationships are stored as a JSON file instead of the data.
Deserialization
During job processing serialized data is deserialized. It mean JSON data that contain primary keys and relationship information is used to pull data from the database.
Problem with the Eloquent model serialization
If you provide a eloquent model instance or collection (like a array of models) to a job class with a constrain added to model’s foreign relation (price > 500), constrain will not be recorded during the serialization process. When you received instance or collection while processing the job class it will have all the records from database without the constrains (price > 500). Only solution is re-constrain. It mean you have to add constrains again inside the handle() method.
Constrains in the relationship models will not include during the serialization process and constrains in the main model will available.
Below example returns authors over 50 years of age and the constrain is added to main model and it will be available in the job class. No need to re-constrain.
$authorsOver50 = Author::with('books')->where('age', '>',50)->get();
Below example has a constrain added to foreign relationship model (Books Model). Constrains added to relationship model (price >500) will be not available on the job class.
$authors = Author::with(['books' => function($query) {
$query->where('price', '>', 500);
}])->find(1);
Example: Demonstrating this issue
Lets create a sample Laravel project to show the issue. Then apply available solutions one by one to solve.
Initial Setup
Follow the instructions on the Laravel documentation to setup a fresh Laravel application. Then install below package to debug.
composer require --dev symfony/var-dumper
Create two models with migrations and factory classes. Please do not care about data types, column names used here.
php artisan make:model Auther -mf
php artisan make:model Book -mf
Modify created migration files
Open author migration file add following code.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('authors', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('name');
$table->integer('age');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('authors');
}
};
Next open book migration file and add following code.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('title');
$table->integer('price');
$table->unsignedBigInteger('author_id');
$table->foreign('author_id')->references('id')->on('authors');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('books');
}
};
Modify factory classes to seed random data
Here we are using seeding feature to quickly add data to database for testing.
Modify AuthorFactory to add random data to name and age field
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class AuthorFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'age' => fake()->numberBetween(20,80),
];
}
}
Modify BookFactory to add random books with a price tag.
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class BookFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->name(),
'price'=> fake()->numberBetween(200, 700)
];
}
}
Create seeders
Open default database Seeder file and add above factories to generate 10 authors with books.
namespace Database\Seeders;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
$auhtors = Author::factory(10)->create();
foreach($auhtors as $author){
Book::factory(10)->create(['author_id' => $author->id]);
}
}
}
Running migrations and seeding
Now we have created migrations, models and seeding. Let’s run and add testing data to database.
php artisan migrate
Next seed testing data to database.
php artisan db:seed
Create a sample job class
Let’s create a sample job class to use eloquent model as the part of the job handling process.
php artisan make:job ProcessBooks
Dispatch created job
Lets use web.php file instead of creating a controller class. Run this job class when this application received a get request to root domain.
<?php
use App\Jobs\ProcessBooks;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
dump('Received a request on the root domain');
$authors = Author::with(['books' => function($query) {
$query->where('price', '>', 500);
}])->find(1);
ProcessBooks::dispatch($authors);
});
Modify Job class to accept eloquent model
Open ProcessBook job class and modify constructor method to accept Author model when using this job.
<?php
namespace App\Jobs;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
class ProcessBooks implements ShouldQueue
{
use Queueable;
public function __construct(public Author $author){}
/**
* Execute the job.
*/
public function handle(): void
{
dump('at job handler');
foreach($this->author->books as $book)
{
dump ($book->title . ' - ' . $book->price);
}
}
}
Testing final result
Run below command to serve web application.
php artisan serve
Run below command to listen for queued jobs.
php artisan queue:work
Then send a get request to root domain. You will see the result in the terminal where you run queue command like below picture. Data on the terminal window will be different than mine since we use random data.
You will see that data on the handle() is incorrect since it shows books where price is less than 500. We passed only the books where price is greater than 500. But in the handle() method of the job class has all the books without the price constrain.
Solving the issue
You can re-constrain or pass only the required part of the model for the job to process.
Solution 1: Re-constrain
Modify handle() method of the job class by adding the constrains you added for model’s foreign relationship earlier in the web.php. In this case only the price constrain.
public function handle(): void
{
dump('at job handler');
$books = $this->author->books()->where('price',">", 500)->get();
foreach($books as $book)
{
dump ($book->title . ' - ' . $book->price);
}
}
Above solution will generate a correct result.
Solution 2: Passing subset of the given relationship.
Modify web.php file to send Book Model instead of Author Model that contain Books. In this test we are assuming only books details are required to process the job. If you need author id and other information, pass another argument to constructor method of the job class.
use App\Jobs\ProcessBooks;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
dump('Received a request on the root domain');
$author = Author::find(1);
$books = $author->books()->where('price','>',500)->get();
ProcessBooks::dispatch($books);
});
Now modify ProcessBooks job class to access Collection class instead of a Author Model class.
<?php
namespace App\Jobs;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
class ProcessBooks implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(public Collection $books)
{
}
/**
* Execute the job.
*/
public function handle(): void
{
dump('at job handler');
foreach($this->books as $book)
{
dump ($book->title . ' - ' . $book->price);
}
}
}
MISC
model’s foreign relation constrain
Imagine authors model has many books. Books model has price. You want to get author model with books where price is greater than 500. Constrain is : price > 500 and it is in a foreign relation not in the main model Author.
$authors = Author::with(['books' => function($query) {
$query->where('price', '>', 500);
}])->find(1);