Laravel 6 - can't access attribute in Pivot class - php

I have :
A many_to_many relationship between a Group Model and a User Model;
A Group_User Pivot class (I need it to observe its events);
An additional field named status in my group_user table;
An observer that observes Group_User model.
The setup is as follows
class Group extends Model
{
/* Other stuff */
protected $table = 'groups';
public function users()
{
return $this->belongsToMany('App\User')
->using(GroupUser::class)
->withPivot(['status', 'is_confirmed'])
->withTimestamps();
}
/* Other stuff */
}
And
class GroupUser extends Pivot
{
protected $table = 'group_user';
}
In a controller, I have a $group->users()->attach($user); (hence creating a new GroupUser object and dispatching the created event).
In my observer, I have the following piece of code:
public function saved(GroupUser $groupUser)
{
// Here, I wanted to access $groupUser->status that leads to an error
dd($groupUser);
}
I expected my $groupUser to have the status attribute as, not only is it in the database, I also specified it in my users() function (just to be sure).
EDIT: here, I meant that when I dd($groupUser->status); it returns null
How can I access this field ?
Some remarks :
I already used in my code $user->pivot->status and it works as expected !
I tried few solutions like explicitly adding status to $attributes or $fillable protected variables of my GroupUser class but that had no effect.
When I dd my $group_user, I get:
//...
#attributes: array:4 [
"user_id" => 1
"group_id" => 1
"created_at" => "2020-10-12 21:07:53"
"updated_at" => "2020-10-12 21:07:53"
]
#original: array:4 [
"user_id" => 1
"group_id" => 1
"created_at" => "2020-10-12 21:07:53"
"updated_at" => "2020-10-12 21:07:53"
]
//...
Finally, I thought of a hack but I don't quite like it as it seems like a lot of work for such a simple task. I wanted to maybe, add a getStatusAttribute function to my model that would return $this->PivotParent->users()->/*try to make some condition to find the correct user*/->pivot->status;
Any ideas ?
EDIT 2: when I pass the extra argument to attach it works but I want to use the database default value for the field
EDIT 3: I found this thread and it made me think that what I want to do is impossible :( I think my attach used to work correctly because (and on only because) my SGBD would set the default value for a field that wasn't specified !

I created your example in my test environment and I had no problem with the Pivot class not returning the data on the saved observer. Perhaps you have to set the guarded at your pivot class?
protected $guarded = []; //accepts all fields to be filled
Here are my files
Group.php
class Group extends Model
{
protected $guarded = [];
public function users()
{
return $this
->belongsToMany(User::class)
->using(GroupUser::class)
->withPivot(['status', 'is_verified'])
->withTimestamps();
}
}
GroupUser.php
class GroupUser extends Pivot
{
protected $guarded = [];
public static function boot()
{
parent::boot();
static::saved(function (GroupUser $groupUser) {
dd($groupUser->toArray());
});
}
}
And my Unit Test
class GroupTest extends TestCase
{
use RefreshDatabase;
/** #test */
public function it_belongs_to_many_users()
{
$this->withoutExceptionHandling();
$user = factory(User::class)->create();
$group = factory(Group::class)->create();
$group
->users()
->attach($user, ['is_verified' => 1, 'status' => 1]);
//data will be dumped here by GroupUser::class saved() observer
}
}
here is My Test Output
array:6 [
"user_id" => 1
"group_id" => 1
"created_at" => "2020-10-12T20:56:20.000000Z"
"updated_at" => "2020-10-12T20:56:20.000000Z"
"is_verified" => 1
"status" => 1
]
I hope that might help you.

OK I found an answer. It's a bit annoying but it works !
WARNING: there is an important remark at the end (edit)
I needed to use withPivotValue (link to the doc, original pull and original proposal) instead of withPivot change my Group->users() so my code now is
class Group extends Model
{
/* Other stuff */
protected $table = 'groups';
public function users()
{
return $this->belongsToMany('App\User')
->using(GroupUser::class)
->withPivotValue(['status' => MY_DEFAULT_VALUE, 'is_confirmed' => MY_OTHER_DEFAULT_VALUE])
->withTimestamps();
}
/* Other stuff */
}
Now that's weird as the doc says :
Set a where clause for a pivot table column.
In addition, new pivot records will receive this value.
What's annoying me is that I have to define the default value in two different places, the migration AND the Model. It would be nice if I had a way to synchronize them.
EDIT 1 : DON'T USE withPivotValue in an existing function or it will break your code ! now my $group->users() returns only users with the status set to the default value so i had to define it in another function called newUsers and keep users as it was
class Group extends Model
{
public function users()
{
return $this->belongsToMany('App\User')
->using(GroupUser::class)
->withPivot(['status', 'is_confirmed'])
->withTimestamps();
}
public function newUsers()
{
return $this->users()
->withPivotValue('status', DEFAULT_STATUS);
}
}

Related

Laravel - hasOne relation based on model attribute

I have a problem, I need to make a relation based on an attribute from the model$this->type, my code is as follows:
class Parent extends Model {
protected $attributes = [
'type' => self::TYPE_A
];
public function child()
{
return match ($this->type) {
self::TYPE_A => $this->hasOne(Cousin::class)->where('type', Cousin::TYPE_A),
self::TYPE_B => $this->hasOne(Cousin::class)->where('type', Cousin::TYPE_B),
self::TYPE_C,
self::TYPE_D => $this->hasOne(Cousin::class)->where('type', Cousin::TYPE_C),
self::TYPE_E,
self::TYPE_F => $this->hasOne(Cousin::class)->where('type', Cousin::TYPE_D)
};
}
public function children()
{
return $this->hasMany(Cousin::class);
}
}
If I do this inside artisan tinker, I get the child relation correctly:
$parent = Parent::first();
$parent->child;
However if my queues execute the code the child relation is returned as null.
If I put logger($this) in the first line of the relation I get an empty model when inside queues:
[2021-09-29 23:51:59] local.DEBUG: {"type":1}
Any idea on how to solve this?
Apparently it is not possible to use the relationship in this way.
The relationship I want uses composite keys and Eloquent doesn't support it.
They created a package to enable this to happen: https://github.com/topclaudy/compoships
But I don't want to add any more packages to my application as it is already too big.
My solution was to add one more column in the cousins table to know which one to use:
public function child() {
return $this->hasOne(Cousin::class)
->where('main', true);
}
protected $attributes = [
'type'
];
public function getTypeAttribute() {
return $this->hasOne(Cousin::class)
->where('main', true);
}
Then call it anywhere
$parent->type
Or you can do it as follow
public function typeMainTrue() {
return $this->hasOne(Cousin::class)
->where('main', true);
}
Then call it anywhere
$parent->typeMainTrue

Laravel Notification Store Additional ID Fields

So I have Laravel Notifications setup and it's working perfectly fine.
However, I've extend the migration to include an additional id field:
$table->integer('project_id')->unsigned()->nullable()->index();
Thing is, I don't see how I can actually set that project_id field. My notification looks like this:
<?php
namespace App\Notifications\Project;
use App\Models\Project;
use App\Notifications\Notification;
class ReadyNotification extends Notification
{
protected $project;
public function __construct(Project $project)
{
$this->project = $project;
}
public function toArray($notifiable)
{
return [
'project_id' => $this->project->id,
'name' => $this->project->full_name,
'updated_at' => $this->project->updated_at,
'action' => 'project-ready'
];
}
}
So ya, I can store it in the data, but what if I want to clear the notification specifically by "project" instead of by "user" or by "notification".
For instance if they delete the project, I want the notifications for it cleared, but there is no way to access that unless I do some wild card search on the data column.
So is there anyway to insert that project_id in the notification ?
You could create an Observer to update the field automatically.
NotificationObserver.php
namespace App\Observers;
class NotificationObserver
{
public function creating($notification)
{
$notification->project_id = $notification->data['project_id'] ?? 0;
}
}
EventServiceProvider.php
use App\Observers\NotificationObserver;
use Illuminate\Notifications\DatabaseNotification;
class EventServiceProvider extends ServiceProvider
{
public function boot()
{
parent::boot();
DatabaseNotification::observe(NotificationObserver::class);
}
}
And you should be able to access the table using the default model to perform actions.
DatabaseNotification::where('project_id', 11)->delete();

Laravel Custom Model Class - Field Does not have a Default Value

So, I have a custom Model extension class called RecursiveModel:
use Illuminate\Database\Eloquent\Model;
use ... RecursiveHelper;
class RecursiveModel extends Model {
private $recursiveHelper = null;
public function __construct(){
$this->recursiveHelper = new RecursiveHelper();
parent::__construct();
}
public function save(array $options = []){
parent::save($options);
}
...
// Additional methods available for Recursive Models (self-referenced `parent_id` relationships)
}
And, a Model that extends this RecursiveModel class instead of the base Model class:
use ... RecursiveModel;
use Illuminate\Database\Eloquent\SoftDeletes;
class Line extends RecursiveModel {
use SoftDeletes;
protected $table = "lines";
protected $primaryKey = "id";
public function parent(){
return $this->belongsTo(self::class, "parent_id", "id");
}
public function children(){
return $this->hasMany(self::class, "parent_id", "id");
}
}
All is well and good, and with previously imported records (back when Line extended Model and not RecursiveModel, I was able to use my RecursiveHelper methods/logic without issue. Now, I'm trying to refresh my database, which calls a Seeder:
use Illuminate\Database\Seeder;
use ... Slugger;
use ... Line;
class LinesSeeder extends Seeder {
public function run(){
$parentLine = Line::create([
"name" => "Line Item",
"slug" => $this->slugger->slugify("Line Item"),
"created_at" => date("Y-m-d H:i:s"),
"updated_at" => date("Y-m-d H:i:s"),
]);
$childLine = Line::create([
"name" => "Child Line Item",
"slug" => $this->slugger->slugify("Child Line Item"),
"parent_id" => $parentLine->id,
"created_at" => date("Y-m-d H:i:s"),
"updated_at" => date("Y-m-d H:i:s"),
]);
...
}
}
As previously stated, when Line extended Model and not RecursiveModel, this code worked without issue. But now, I'm running into this error:
SQLSTATE[HY000]: General error: 1364 Field 'name' doesn't have a default value (SQL: insert into lines
(updated_at, created_at) values (2018-08-13 15:56:45, 2018-08-13 15:56:45))
The Line::create([...]); doesn't seem to be receiving the parameter passed; is there something I'm missing when extending Model.php? I've tried adding:
public function create(array $options = []){
parent::create($options);
}
To RecursiveModel, but that just throws another error (and I don't think the create() method is a part of Model.php, but rather Builder.php.)
Also, it's not an issue with protected $fillable, nor is it an issue with setting 'strict' => true, on my mysql connection; already tried both of those to no avail.
As suggested, updated __construct method of RecursiveModel to:
public function __construct(array $attributes = []){
$this->recursiveHelper = new RecursiveHelper();
return parent::__construct($attributes);
}
Unfortunately, still getting the same error.
Edit: Line.php had a __construct method that was carried over from when I was applying $this->recursiveHelper model by model; solution was to update signature to match (as noted above) or remove __construct from extending models.
Model constructors need to take in an array of attributes:
public function __construct(array $attributes = [])

How To Cast Eloquent Pivot Parameters?

I have the following Eloquent Models with relationships:
class Lead extends Model
{
public function contacts()
{
return $this->belongsToMany('App\Contact')
->withPivot('is_primary');
}
}
class Contact extends Model
{
public function leads()
{
return $this->belongsToMany('App\Lead')
->withPivot('is_primary');
}
}
The pivot table contains an additional param (is_primary) that marks a relationship as the primary. Currently, I see returns like this when I query for a contact:
{
"id": 565,
"leads": [
{
"id": 349,
"pivot": {
"contact_id": "565",
"lead_id": "349",
"is_primary": "0"
}
}
]
}
Is there a way to cast the is_primary in that to a boolean? I've tried adding it to the $casts array of both models but that did not change anything.
In Laravel 5.4.14 this issue has been resolved. You are able to define a custom pivot model and tell your relationships to use this custom model when they are defined. See the documentation, under the heading Defining Custom Intermediate Table Models.
To do this you need to create a class to represent your pivot table and have it extend the Illuminate\Database\Eloquent\Relations\Pivot class. On this class you may define your $casts property.
<?php
namespace App;
use Illuminate\Database\Eloquent\Relations\Pivot;
class CustomPivot extends Pivot
{
protected $casts = [
'is_primary' => 'boolean'
];
}
You can then use the using method on the BelongsToMany relationship to tell Laravel that you want your pivot to use the specified custom pivot model.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Lead extends Model
{
public function contacts()
{
return $this->belongsToMany('App\Contact')->using('App\CustomPivot');
}
}
Now, whenever you access your pivot by using ->pivot, you should find that it is an instance of your custom pivot class and the $casts property should be honoured.
Update 1st June 2017
The issue raised in the comments by #cdwyer regarding updating the pivot table using the usual sync/attach/save methods is expected to be fixed in Laravel 5.5 which is due to be released next month (July 2017).
See Taylor's comment at the bottom of this bug report and his commit, fixing the issue here.
Since this is an attribute on the pivot table, using the $casts attribute won't work on either the Lead or Contact model.
One thing you can try, however, is to use a custom Pivot model with the $casts attribute defined. Documentation on custom pivot models is here. Basically, you create a new Pivot model with your customizations, and then update the Lead and the Contact models to use this custom Pivot model instead of the base one.
First, create your custom Pivot model which extends the base Pivot model:
<?php namespace App;
use Illuminate\Database\Eloquent\Relations\Pivot;
class PrimaryPivot extends Pivot {
protected $casts = ['is_primary' => 'boolean'];
}
Now, override the newPivot() method on the Lead and the Contact models:
class Lead extends Model {
public function newPivot(Model $parent, array $attributes, $table, $exists) {
return new \App\PrimaryPivot($parent, $attributes, $table, $exists);
}
}
class Contact extends Model {
public function newPivot(Model $parent, array $attributes, $table, $exists) {
return new \App\PrimaryPivot($parent, $attributes, $table, $exists);
}
}
Good news! Tylor already fixed this bug:
https://github.com/laravel/framework/issues/10533
In Laravel 5.1 or higher you can use dot notation for pivot casts:
protected $casts = [
'id' => 'integer',
'courses.pivot.course_id' => 'integer',
'courses.pivot.active' => 'boolean'
]
The answer provided by #patricus above is absolutely correct, however, if like me you're looking to also benefit from casting out from JSON-encoded strings inside a pivot table then read on.
The Problem
I believe that there's a bug in Laravel at this stage. The problem is that when you instantiate a pivot model, it uses the native Illuminate-Model setAttributes method to "copy" the values of the pivot record table over to the pivot model.
This is fine for most attributes, but gets sticky when it sees the $casts array contains a JSON-style cast - it actually double-encodes the data.
A Solution
The way I overcame this is as follows:
1. Set up your own Pivot base class from which to extend your pivot subclasses (more on this in a bit)
2. In your new Pivot base class, redefine the setAttribute method, commenting out the lines that handle JSON-castable attributes
class MyPivot extends Pivot {
public function setAttribute($key, $value)
{
if ($this->hasSetMutator($key))
{
$method = 'set'.studly_case($key).'Attribute';
return $this->{$method}($value);
}
elseif (in_array($key, $this->getDates()) && $value)
{
$value = $this->fromDateTime($value);
}
/*
if ($this->isJsonCastable($key))
{
$value = json_encode($value);
}
*/
$this->attributes[$key] = $value;
}
}
This highlights the removal of the isJsonCastable method call, which will return true for any attributes you have casted as json, array, object or collection in your whizzy pivot subclasses.
3. Create your pivot subclasses using some sort of useful naming convention (I do {PivotTable}Pivot e.g. FeatureProductPivot)
4. In your base model class, change/create your newPivot method override to something a little more useful
Mine looks like this:
public function newPivot(Model $parent, array $attributes, $table, $exists)
{
$class = 'App\Models\\' . studly_case($table) . 'Pivot';
if ( class_exists( $class ) )
{
return new $class($parent, $attributes, $table, $exists);
}
else
{
return parent::newPivot($parent, $attributes, $table, $exists);
}
}
Then just make sure you Models extend from your base model and you create your pivot-table "models" to suit your naming convention and voilĂ  you will have working JSON casts on pivot table columns on the way out of the DB!
NB: This hasn't been thoroughly tested and may have problems saving back to the DB.
I had to add some extra checks to have the save and load functions working properly in Laravel 5.
class BasePivot extends Pivot
{
private $loading = false;
public function __construct(Model $parent, array $attributes, $table, $exists)
{
$this->loading = true;
parent::__construct($parent, $attributes, $table, $exists);
$this->loading = false;
}
public function setAttribute($key, $value)
{
// First we will check for the presence of a mutator for the set operation
// which simply lets the developers tweak the attribute as it is set on
// the model, such as "json_encoding" an listing of data for storage.
if ($this->hasSetMutator($key)) {
$method = 'set'.Str::studly($key).'Attribute';
return $this->{$method}($value);
}
// If an attribute is listed as a "date", we'll convert it from a DateTime
// instance into a form proper for storage on the database tables using
// the connection grammar's date format. We will auto set the values.
elseif ($value && (in_array($key, $this->getDates()) || $this->isDateCastable($key))) {
$value = $this->fromDateTime($value);
}
/**
* #bug
* BUG, double casting
*/
if (!$this->loading && $this->isJsonCastable($key) && ! is_null($value)) {
$value = $this->asJson($value);
}
$this->attributes[$key] = $value;
return $this;
}
}

Updating timestamps on attaching/detaching Eloquent relations

I'm using Laravel 4, and have 2 models:
class Asset extends \Eloquent {
public function products() {
return $this->belongsToMany('Product');
}
}
class Product extends \Eloquent {
public function assets() {
return $this->belongsToMany('Asset');
}
}
Product has the standard timestamps on it (created_at, updated_at) and I'd like to update the updated_at field of the Product when I attach/detach an Asset.
I tried this on the Asset model:
class Asset extends \Eloquent {
public function products() {
return $this->belongsToMany('Product')->withTimestamps();
}
}
...but that did nothing at all (apparently). Edit: apparently this is for updating timestamps on the pivot table, not for updating them on the relation's own table (ie. updates assets_products.updated_at, not products.updated_at).
I then tried this on the Asset model:
class Asset extends \Eloquent {
protected $touches = [ 'products' ];
public function products() {
return $this->belongsToMany('Product');
}
}
...which works, but then breaks my seed which calls Asset::create([ ... ]); because apparently Laravel tries to call ->touchOwners() on the relation without checking if it's null:
PHP Fatal error: Call to undefined method Illuminate\Database\Eloquent\Collection::touchOwners() in /projectdir/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php on line 1583
The code I'm using to add/remove Assets is this:
Product::find( $validId )->assets()->attach( $anotherValidId );
Product::find( $validId )->assets()->detach( $anotherValidId );
Where am I going wrong?
You can do it manually using touch method:
$product = Product::find($validId);
$product->assets()->attach($anotherValidId);
$product->touch();
But if you don't want to do it manually each time you can simplify this creating method in your Product model this way:
public function attachAsset($id)
{
$this->assets()->attach($id);
$this->touch();
}
And now you can use it this way:
Product::find($validId)->attachAsset($anotherValidId);
The same you can of course do for detach action.
And I noticed you have one relation belongsToMany and the other hasMany - it should be rather belongsToMany in both because it's many to many relationship
EDIT
If you would like to use it in many models, you could create trait or create another base class that extends Eloquent with the following method:
public function attach($id, $relationship = null)
{
$relationship = $relationship ?: $this->relationship;
$this->{$relationship}()->attach($id);
$this->touch();
}
Now, if you need this functionality you just need to extend from another base class (or use trait), and now you can add to your Product class one extra property:
private $relationship = 'assets';
Now you could use:
Product::find($validId)->attach($anotherValidId);
or
Product::find($validId)->attach($anotherValidId, 'assets');
if you need to attach data with updating updated_at field. The same of course you need to repeat for detaching.
From the code source, you need to set $touch to false when creating a new instance of the related model:
Asset::create(array(),array(),false);
or use:
$asset = new Asset;
// ...
$asset->setTouchedRelations([]);
$asset->save();
Solution:
Create a BaseModel that extends Eloquent, making a simple adjustment to the create method:
BaseModel.php:
class BaseModel extends Eloquent {
/**
* Save a new model and return the instance, passing along the
* $options array to specify the behavior of 'timestamps' and 'touch'
*
* #param array $attributes
* #param array $options
* #return static
*/
public static function create(array $attributes, array $options = array())
{
$model = new static($attributes);
$model->save($options);
return $model;
}
}
Have your Asset and Product models (and others, if desired) extend BaseModel rather than Eloquent, and set the $touches attribute:
Asset.php (and other models):
class Asset extends BaseModel {
protected $touches = [ 'products' ];
...
In your seeders, set the 2nd parameter of create to an array which specifies 'touch' as false:
Asset::create([...],['touch' => false])
Explanation:
Eloquent's save() method accepts an (optional) array of options, in which you can specify two flags: 'timestamps' and 'touch'. If touch is set to false, then Eloquent will do no touching of related models, regardless of any $touches attributes you've specified on your models. This is all built-in behavior for Eloquent's save() method.
The problem is that Eloquent's create() method doesn't accept any options to pass along to save(). By extending Eloquent (with a BaseModel) to accept the $options array as the 2nd attribute, and pass it along to save(), you can now use those two options when you call create() on all your models which extend BaseModel.
Note that the $options array is optional, so doing this won't break any other calls to create() you might have in your code.

Categories