I'm looking to sort of "intercept" and change a field in a model before it's send back to the client. I have an API with endpoints similar to the following:
Route::get('users/{user}', 'Api\UserController#user');
My application uses vinkla/laravel-hashids, so as far as the client application is concerned, the ID to query for should be something like K6LJwKJ1M8, and not 1. Currently I can query for a user providing the hash-id, but the response comes back with the numeric/auto-incrementing ID.
e.g. For a query such as /api/users/K6LJwKJ1M8 I receive the following response:
{
"id": 1,
"address": null,
"telephone": null,
"name": "TestNameHere",
"description": null,
...
}
Is there a nice way in Laravel that I could modify all user objects being returned in responses to use the vinkla/laravel-hashids ID, instead of the auto-incrementing ID?
Ideally, the above response would become:
{
"id": K6LJwKJ1M8,
"address": null,
"telephone": null,
"name": "TestNameHere",
"description": null,
...
}
I was thinking something like using getRouteKey would work, but it doesn't change the object that's sent out in the JSON response.
e.g.
public function getRouteKey() {
return Hashids::encode($this->attributes['id']);
}
It'd be nice if there was one place to change this since my application has around 40 different routes that I'd otherwise need to change "manually" (e.g. before sending the response do something like $user->id = Hashids::encode($id))
You can implement the CastsAttributes interface:
Classes that implement this interface must define a get and set method. The get method is responsible for transforming a raw value from the database into a cast value, while the set method should transform a cast value into a raw value that can be stored in the database.
Inside your app directory create a directory named Casts and create a class named UserCode:
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use ...\Hashids;
class UserCode implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return Hashids::encode($value);
}
public function set($model, $key, $value, $attributes)
{
return Hashids::decode($value);
}
}
Then in your user model add the UserCode cast to the id value:
use App\Casts\UserCode; // <--- make sure to import the class
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $casts = [
'id' => UserCode::class,
];
}
Then if you would do User::find(1)->id you will get the hashed value or visit /user/1 it will return the user with the hashed id. credit.
Note that you can't find the user by the hashed id unless you implemented something e.g. /user/hashed_id will give 404.
You can use API Resources
https://laravel.com/docs/7.x/eloquent-resources#introduction
API Resource acts as a transformation layer that sits between your
Eloquent models and the JSON responses that are actually returned to
your application's users.
You may create an API resource for the user and use it wherever you're returning the user in the response.
Api resources gives you a lot more control, you could manipulate whatever field you want, send some extra fields using the combination of a few fields, change the name of the fields that you want in your response (xyz => $this->name)
UserResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request)
{
//You can access model properties directly using $this
return [
'id' => Hashids::encode($this->id),
"address": $this->address,
"telephone": $this->telephone,
"name": $this->name,
"description": $this->description,
...
];
}
}
And then wherever you want to return a user instance.
Controller
// $user is a User Model Instance.
return new UserResource($user);
In case you have a collection
// $users is a collection of User Model instances.
return UserResource::collection($users);
UserResource will be applied for every model instance in your collection and then returned to you as your JSON response.
To achieve what you want, in your model, you would have to use set getIncrementing to false and ensure getKeyType is set to string.
class YourModel extends Model
{
public function getIncrementing()
{
return false;
}
public function getKeyType()
{
return 'string';
}
}
The above would work if you were using uuid and have the following in your migration file:
$table->uuid('id')->primary();
You would probably have to find a way to modify the package to use this approach.
Related
I am using Laravel to fetch records from the database for which I have created an apiResource controller. I have setup the following code inside routes.
Route::apiResource('/MyController',MyController::class)->middleware('auth:api');
In MyController.php my code to display a specific data is:
/**
* Display the specified resource.
*
* #param \App\Models\ContentType $contentType
* #return \Illuminate\Http\Response
*/
public function show(MyModel $Model)
{
// show content type info
return response([
'data' => new MyControllerResource($Model)
],200);
}
I get the data when I place an api call like this:
http://localhost:8000/api/MyController/1
What I want is a record getting fetched by passing other field value instead of id in the route. For example.
http://localhost:8000/api/MyController/mypost
Any idea how can I achieve this?
The route key name defaults to id for all models. You will want to update this to name or whatever field "mypost" is by adding a getRouteKeyName() method.
<?php
namespace App;
...
class Post extends Model
{
public function getRouteKeyName()
{
return 'name';
}
...
}
You are using route model binding. And in laravel its default behaviour is to find model with id and return collection. It will not search for any other field. Of course you can change this behaviour but it can only search data by one field. To change this behaviour use getRouteKeyName method in model like:
public function getRouteKeyName()
{
return 'another_field_in_my_table';
}
You can also use explicit binding like:
Route::bind('MyController', function($value) {
return \App\MyModel::where('id', $value)->orWhere('another_field_in_my_table', $value)->first();
});
To learn more about explicit binding check docs.
You'll have to define route separately. You can group the routes by controller and middleware though. And once done, then, Inside your route, you need to change to this:
Route::get('/show/{post:columnName}', [ MyController::class, 'show' ])->middleware('auth:api');
Now your data will be fetched on the basis of your column name defined in the route.
I am trying to define a model mutator
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Custom_fields extends Model
{
protected $table = 'custom_fields';
public function setContentAttribute($value){
return unserialize($value);
}
}
and on test controller, tried
Custom_fields::all()[0]->content;
Unfortunately, it is not working; the value is still not an array (serialize)
I want to access the field content and unserialize the value of the field so I can get an array automatically on retrieve, but it's not working.
You're trying to "get an array automatically on retrieve" with a method called setContentAttribute. You are confusing accessors and mutators. As the method name implies, the mutator is called when you set the content attribute.
To create an accessor that is called when you get the content attribute:
public function getContentAttribute($value)
{
return unserialize($value);
}
Likely if this data is to be stored in a serialized form you want your mutator to look like this:
public function setContentAttribute($value)
{
$this->attributes["content"] = serialize($value);
}
But really, you should not be using these serialization functions at all. Laravel can natively convert objects to JSON for storage in a database. Simply define the property in your model's $casts property and data will be converted automatically when getting or setting the property.
protected $casts = [
"content" => "array",
];
In Laravel 8 it is possible to quickly fill relationships with factories. However, I cannot figure out how to generate more than one relationship. How can I create a random or new relationship for each link using the new Laravel 8 syntax?
This factory syntax is only available in Laravel 8.
https://laravel.com/docs/8.x/database-testing#factory-relationships
Problem
Consider the following relationship:
Each link belongs to a website and a post.
Both websites and posts can have many links.
<?php
class Post extends Model
{
use HasFactory;
function links()
{
return $this->hasMany(Link::class);
}
}
class Website extends Model
{
use HasFactory;
function links()
{
return $this->hasMany(Link::class);
}
}
class Link extends Model
{
use HasFactory;
function post()
{
return $this->belongsTo(Post::class);
}
function website()
{
return $this->belongsTo(Website::class);
}
}
What I tried/want
What I tried below will only generate one model for all the links. How can I create a random or new relationship for each link using the new Laravel 8 syntax?
Link::factory()->count(3)->forPost()->forWebsite()->make()
=> Illuminate\Database\Eloquent\Collection {#4354
all: [
App\Models\Link {#4366
post_id: 1,
website_id: 1,
},
App\Models\Link {#4395
post_id: 1, // return a different ID
website_id: 1,
},
App\Models\Link {#4370
post_id: 1, // return a different ID
website_id: 1, // return a different ID
},
],
}
Just add this to your LinkFactory:
public function definition()
{
return [
'post_id' => function () {
return Post::factory()->create()->id;
},
.....
];
}
And now you can create new Post for each new Link:
Link::factory()->count(3)->create();//Create 3 links with 3 new posts
or attach new Links to existing Post:
Link::factory()->count(3)->create(['post_id' => Post::first()->id]); //create 3 links and 0 new posts
In Laravel 9, you can use this macro:
// database/factoryMacros.php
<?php
namespace Database\Support;
use Illuminate\Database\Eloquent\Factories\BelongsToRelationship;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** #param Factory|Model $factory */
Factory::macro('hasParent', function (mixed $factory, string $relationship = null): self {
return $this
->state(function () use ($factory, $relationship): array {
$belongsTo = new BelongsToRelationship(
factory: $factory,
relationship: $relationship ?? guessBelongsToMethodName($factory),
);
return $belongsTo
->recycle(recycle: $this->recycle)
->attributesFor(model: $this->newModel());
});
});
Factory::macro('hasChildren', fn (...$arguments): self => $this->has(...$arguments));
Factory::macro('hasChild', fn (...$arguments): self => $this->has(...$arguments));
/** #param Factory|Model $factory */
function guessBelongsToMethodName(mixed $factory): string
{
$modelName = is_subclass_of($factory, Factory::class)
? $factory->modelName()
: $factory::class;
return Str::camel(class_basename($modelName));
}
Usage
Use the method hasParent($factory) instead of for($factory):
// Creates 3 Link, 3 Post, 3 Website
Link::factory()
->count(3)
->hasParent(Post::factory())
->hasParent(Website::factory())
->make();
You can also use hasChildren($factory) or hasChild($factory) instead of has for name consistency:
// Creates 3 Post, 3 Link
Post::factory()
->count(3)
->hasChild(Link::factory())
->make();
The syntax of the macros is the same as for and has.
You can explicitly define the relationship name, pass complex factory chains, pass a concrete model, and use it with recycle, for example.
Installation
Add the file to your composer.json:
{
...
"autoload": {
"files": [
"database/factoryMacros.php"
]
}
}
Run a composer dump-autoload to reload the composer file.
Alternatively, you can register the macro as a service or load it as a mixin.
PS: I intend to create a library for this in the future.
Tests
/**
* Use "DatabaseEloquentFactoryTest.php" as base:
* https://github.com/laravel/framework/blob/de42f9987e01bfde50ea4a86becc237d9c8c5c03/tests/Database/DatabaseEloquentFactoryTest.php
*/
class FactoryMacrosTest extends TestCase
{
function test_belongs_to_relationship()
{
$posts = FactoryTestPostFactory::times(3)
->hasParent(FactoryTestUserFactory::new(['name' => 'Taylor Otwell']), 'user')
->create();
$this->assertCount(3, $posts->filter(function ($post) {
return $post->user->name === 'Taylor Otwell';
}));
$this->assertCount(3, FactoryTestUser::all());
$this->assertCount(3, FactoryTestPost::all());
}
}
TL;DR;
In Laravel 9, it is not possible to achieve this. The for() uses a single model for all instances.
There's a PR to fix this behavior, but the PR was closed, and I'm not sure it will ever be implemented:
https://github.com/laravel/framework/pull/44279
The laravel magic factory method for allows you to populate the database with one record from the foreign table. See link to documentation https://laravel.com/docs/8.x/database-testing#belongs-to-relationships
In your case, using forPost() and forWebsite() will allow you to populate the database with one id from the Post table and the Website table.
If you want to use different IDs use this syntax instead
Link::factory()->count(3)->make()
Had a similar problem and was only able to get it working when I attached within the afterCreating() on a single factory. This allows me to create/store the id of each model and then attach to the Link model
I'm choosing to start with WebsiteFactory but you can also start with PostFactory since those are the "highest parent" models. If you try to make a Link without the website_id and the post_id I believe you will get a error asking for both.
class WebsiteFactory extends Factory
{
public function definition(){...}
public function configure()
{
return $this->afterCreating( function (Website $website){
// the website model is created, hence after-creating
// attach Website to a new Post
$post = Post::factory()->hasAttached($website)->create();
// create new links to attach to both
$links = Link::factory()->for($website)->for($post)->count(3)->create();
});
You can leave PostFactory and LinkFactory as simple definition blocks (or add other stuff if you wanted). Now when you create a new Website via factory, it will create a new post and 3 new links. For example, you can now run
php artisan tinker
$w = Website::factory()->create(); // one website-one post-3 links
$ws = Website::factory()->count(5)->create(); // 5 website-5 post- 15 links
Check out the Factory Callbacks here (9.x docs, but they are in 8.x too):
https://laravel.com/docs/9.x/database-testing#factory-callbacks
\App\Models\Category::factory(10)
->has(Product::factory()->count(10), 'products')
->create();
It would be better if you play around with code. You will understand better.
$user = User::factory()
->has(Post::factory()->count(3), 'posts')
->create();
The above code will create three post for a single user. It will insert three post row and a user row. On the other hand the code below, seems three post will be inserted for user with name Jessica Aercher, that is it won't insert a user.
$posts = Post::factory()
->count(3)
->for(User::factory()->state([
'name' => 'Jessica Archer',
]))
->create();
Reason
I got a legacy system with a table containing slugs.
When those records match, it represents some kind of page with a layout ID.
Because these pages can have different resource needs it depends on the layout ID which tables can be joined with.
I use Laravel's Eloquent models.
What I would like is to have a child model that holds the layout specific relations.
class Page extends Model {
// relation 1, 2, 3 that are always present
}
class ArticlePage extends Page {
// relation 4 and 5, that are only present on an ArticlePage
}
However in my controller, in order to know which layout I need, I already have to query:
url: /{slug}
$page = Slug::where('slug', $slug)->page;
if ($page->layout_id === 6) {
//transform $page (Page) to an ArticlePage
}
Because of this I get an instance of Page, but I would like to transform or cast it to an instance of ArticlePage to load it's additional relations. How can I achieve this?
You'll need to look into Polymorphic relations in Laravel to achieve this. A Polymorphic Relation would allow you to retrieve a different model based on the type of field it is. In your Slug model you would need something like this:
public function page()
{
return $this->morphTo('page', 'layout_id', 'id');
}
In one of your service providers, e.g. AppServiceProvider you would need to provide a Morph Map to tell Laravel to map certain IDs to certain model classes. For example:
Relation::morphMap([
1 => Page::class,
// ...
6 => ArticlePage::class,
]);
Now, whenever you use the page relation, Laravel will check the type and give you the correct model back.
Note: I'm not 100% sure on the parameters etc. and I haven't tested but you should be able to work it out from the docs.
If your layout_id is on the Page model, the only solution I see is to add a method to your Page model that is able to convert your existing page into an ArticlePage, or other page type, based on its layout_id property. You should be able to try something like this:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Page extends Model
{
const LAYOUT_ARTICLE = 6;
protected $layoutMappings = [
// Add your other mappings here
self::LAYOUT_ARTICLE => ArticlePage::class
];
public function toLayoutPage()
{
$class = $this->layoutMappings[$this->layout_id];
if (class_exists($class)) {
return (new $class())
->newInstance([], true)
->setRawAttributes($this->getAttributes());
}
throw new \Exception('Invalid layout.');
}
}
What this does is look for a mapping based on your layout_id property, and then it creates a new class of the correct type, filling its attributes with those from the page you're creating from. This should be all you need, if you take a look at Laravel's Illuminate\Database\Eloquent::newFromBuilder() method, which Laravel calls when it creates new model instances, you can see what's going on and how I've gotten the code above. You should be able to just use it like this:
$page = Slug::where('slug', $slug)
->first()
->page
->toLayoutPage();
That will give you an instance of ArticlePage
As far as I know there is no built in function for this.
But you could do something like this.
$page = Slug::where('slug', $slug)->page;
if ($page->layout_id === 6) {
$page = ArticlePage::fromPage($page);
}
And then on the ArticlePage create the static method
public static function fromPage(Page $page)
{
$articlePage = new self();
foreach($page->getAttributes() as $key => $attribute) {
$articlePage->{$key} = $attribute;
}
return $articlePage
}
Depending on your use-case might be smart to create a static method that does this automatically on the relation page() for Slug.
We've been coding an API service with Yii2 and created all model classes extending from ActiveRecord.
Next we started adding the simple relations. This is where things got strange.
We have 3 tables (limited to explain the problem) 'app_customers', 'lu_postcodes' and 'lu_countries'.
A customer has one postcode_id.
A postcode has one country_id.
In the customer model we would like to add relationships to get the postcode and country data when getting customer data.
Customer model:
namespace api\modules\v1\models;
use Yii;
use \api\common\components\BaseModel;
/**
*
*/
class Customer extends BaseModel {
public function extraFields() {
return ['postcode'];
}
public function getPostcode() {
return $this->hasOne(Postcode::className(), ['id' => 'postcode_id'])
->with(['country']);
}
....[more code]
Postcode model:
namespace api\modules\v1\models;
use Yii;
use \api\common\components\BaseModel;
/**
*
*/
class Postcode extends BaseModel {
public function extraFields() {
return ['country'];
}
public function getCountry() {
return $this->hasOne(Country::className(), ['id' => 'country_id']);
}
....[more code]
So when calling v1/customer?expand=postcode it returns all customer data and postcode is populated with a postcode object. But we can't get the country data to load together with the postcode data.
In an ideal situation we would like the index and view actions from customer to include both postcode and country data. (did not try yet .. one step at a time :) )
Trying to debug this issue We dumped the sql from Customer::getPostcode
with var_dump($q->createCommand()->sql); and got the output:
'SELECT * FROM `lu_postcodes` WHERE `id`=:qp0'
This might have something to do with missing tablenames?
In earlier attempt I managed to get the country data loaded, but it used the ID from customer which resulted in a wrong country obviously.
Any ideas? Thanks in advance!
I did some research and found https://github.com/yiisoft/yii2/issues/6844#issuecomment-131482508, which looked promising, but once implemented it still remained the same result.
----------------------- EDIT BELOW
I tried all options and in most cases I get a JSON parse error from Yii. In one case I get a result like
...
"display_name": "Rxxxxxxx xxxxxxx",
"postcode": {
"id": 361,
"country_id": 20,
"state_id": 2,
"zip": "3945",
"city": "Ham",
"alpha": "ham",
"longitude": "5.1730329000",
"latitude": "51.0966039000"
},
"country": null
...
Used option: Adding country in extraFields() in Customer.
public function extraFields() {
return ['postcode', 'country' => function($model) { return $model->postcode->country; }];
}
Yii2 itself does not directly support nested relations . It is not good idea to build such complex objects in a REST API using ActiveRecord classes. Remember you will likely have a collection API i.e. a group of customer , each customer will need multiple subqueries to satisfy the request.
There are multiple ways to address this.
Use separate controller to solve this /customer/<id>/country. This is good solution if country/ postcode is a hasMany relationship
Define country in the default fields() functions of postcode instead of extraFields(). This way when you pass expand?=postcode, both postcode and country will always show in the expanded output
Use ad-hoc expansion function in your extraFields() definition of Customer Class with something like this
.
public function extraFields()
{
return [
'postcode',
'country'=>function($model){
return $model->postcode->country;
}
];
}
Define a country function in your Customer class using Via Relation, this is more useful when you have hasMany relationships
.
public function getCountry()
{
return $this->hasOne(Country::className(), ['id' => 'country_id'])
->via('postCode');
}
Define a custom query with the exact parameters you desire and use a dataProvider as the response to your Index function something like this
..
public function actionIndex()
{
$query = (new \yii\db\Query())
// This can also be a ActiveQuery
// using Customer::find() with addSelects() etc
// ... additional query conditions
return new ActiveDataProvider(['query'=>$query]);
}
Similarly
public function actionView($id)
{
$query = (new \yii\db\Query()) // simlar to above
$query->andWhere(['customer.id'=>(int)$id]); // or similar condition
return $query->one();
}
I got it working.
It was an encoding issue...
'charset' => 'UTF-8', in the db config array did the trick.