Framework: Carbon serializeUsing not applicable when serializing model instance

Created on 17 Oct 2017  路  22Comments  路  Source: laravel/framework

  • Laravel Version: 5.5.14
  • PHP Version: 7.1.9
  • Database Driver & Version: MySQL 5.7.19

Description:

Not sure this is a intended behavior or a bug. The date attributes of a model is not affected by the Carbon::serializeUsing feature when serializing the whole model.

Suggestion: If you could introduce a feature which can specify serialized date format on each model attribute will be great. Example,
protected $dateFormats = ['dob' => 'Y-m-d'];

Steps To Reproduce:

AppServiceProvider.php
\Illuminate\Support\Carbon::serializeUsing(function ($carbon) { return $carbon->format('Y-m-d'); });

App\User.php
protected $dates = ['dob'];

HomeController.php
dd(Auth::user()->jsonSerialize());

bug

Most helpful comment

Has there been any progress concerning this issue.

All 22 comments

You need to use toJson() instead of jsonSerialize ()

Nope, same result using toJson()

HomeController.php
return Auth::user()->toJson();

will still give me
{"id":1, ... , "dob":"1968-08-04 00:00:00"}

@lampard7824 I had the same problem. after diving in the code of the Model class, date attributes are serialized with the specified date format (see getDateFormat() and $dateFormat to override) with a fallback to the database date format. You can override $dateFormat to use your own. It works but this also affects the expected format when setting the value whereas we mostly want to change the format only during serialization. To change the format only during serialization, you have to override the serializeDate() method like this:

protected function serializeDate(\DateTimeInterface $date)
{
    return $date->format(\DateTime::W3C); // Use your own format here
}

See this answer on StackOverflow for an elegant (imho) fix to this issue.

The Laravel documentation is a little bit misleading about date serialization.

@bgondy I understand this approach is available. is just that since Laravel 5.5 introduced this \Illuminate\Support\Carbon::serializeUsing() new feature, I would expect that this will have effect on serialization on all \Illuminate\Support\Carbon instances throughout the whole application, including when serializing eloquent model.

@themsaid @lampard7824 is right. This issue should be reopened.
What usage does Illuminate\Support\Carbon::serializeUsing() have if it is not for serialization!?

Cited from documentation: "To customize how all Carbon dates throughout your application are serialized, use the Carbon::serializeUsing method."

@daviian still can't replicate return Auth::user()->toJson(); returns a correctly formatted date, this issue needs more information for us to look into, the basic steps of replication above doesn't reproduce the issue on a fresh laravel installation.

@themsaid , appreciate that you coming back to this issue.

Here's the step to reproduce, after creating a new laravel 5.5 app

  1. run php artisan make:auth
  2. In AppServiceProvider@boot, add the serializeUsing as below:
public function boot() 
{
        \Illuminate\Support\Carbon::serializeUsing(function ($carbon) { 
            return $carbon->format('dmY'); 
        });
}
  1. Register a user account using the default Auth register page (app.dev/register)
  2. In App\Http\Controllers\HomeController@index,
public function index()
{
        return \Auth::user()->toJson(); // this does not work, the created_at and updated_at attribute still showing the default Y-m-d H:i:s format
        return \Auth::user()->created_at; // this works, which returns expected dmY format 
}
  1. browse to the home url, app.dev/home , and check on the returned result

  2. Not sure whether this is intended behavior, but I am expecting after I set the Carbon::serializeUsing to a custom format, all the model instance when serialized to Json, the date attributes should be following that custom format as well, instead of the "Y-m-d H:i:s" format.

I would expect the serialized Json to be {"id":2,"name":"test","email":"[email protected]","created_at":"30102017","updated_at":"30102017"}

instead of {"id":2,"name":"test","email":"[email protected]","created_at":"2017-10-30 16:13:10","updated_at":"2017-10-30 16:13:10"}

Ok now I see where the confusion is, when you do toArray() Laravel will convert all Carbon instances to a string using the model date format, so by the time we're serializing to JSON the date is already a string.

However manually returning a Carbon instance in your toArray() method or a resource toArray() method now you'd get the serialization you want.

public function toArray(){
        return [
            'created_at' => $this->created_at
        ];
    }

My comment here is not valid: https://github.com/laravel/framework/issues/21703#issuecomment-337234425

I was testing using a resource rather than an Eloquent instance.

@themsaid Regarding your proposal to override the toArray function. IMHO this should be the default behaviour, or does this approach have any downsides?
And to go a step further. I guess this will make the $dateFormat attribute obsolete?

Currently I have no real way of making sure that serialized (json_encode) models use the correct formatting due to this bug.

For now I wrote a hack that that fixes the issue. You just need to add it to your BaseModel for it to work.

/**
 * {@inheritdoc}
 *
 * @override
 *
 * @todo This is a temporary hack until the following issue has been fixed https://github.com/laravel/framework/issues/21703
 */
protected function addDateAttributesToArray(array $attributes)
{
    $stack = array_map(function ($trace) {
        return $trace['function'];
    }, debug_backtrace());

    $json = in_array('jsonSerialize', $stack);

    foreach ($this->getDates() as $key) {
        if (!isset($attributes[$key])) {
            continue;
        }

        $date = $this->asDateTime($attributes[$key]);

        if ($json) {
            $attributes[$key] = $date->jsonSerialize();
        } else {
            $attributes[$key] = $this->serializeDate($date);
        }
    }

    return $attributes;
}
// Somewhere in your AppServiceProvider::boot or something
Carbon::serializeUsing(function ($carbon) {
    return $carbon->toAtomString();
});

I'm stuck with the same problem after wasting all day in total frustration!

I followed the instructions in the Laravel 5.6 docs and added this to my AppServiceProvider:

  public function boot()
    {
        Carbon::serializeUsing(function ($carbon) {
            return $carbon->toRfc2822String();
        });
    }

I then return the whole Eloquent model to an Ajax request, sometimes a View.

As far as I understand, the Model is automatically serialized, right?

So why am I not seeing the Carbon dates properly formatted?

And why is the Laravel 5.6 doc showing an AppServiceProvider that looks different than the one from Laravel 5.5 or 5.6? For a moment I thought I put it into the wrong place! All the doc blocks look totally different.

I'm also totally bewildered by the fact that the default date format is not what Javascript pretty much demands in all the places. I have moment.js screaming at me why my date/times are not RFC or ISO and that it will fail soon.

Has there been any progress concerning this issue.

Is there any update to this Issue?

No I don't think so, but I would really appreciate somebody with a little more knowledge of the framework's internals to hook into this.
Whether it is wanted or unwanted behavior, the documentation is definitely easy to misunderstand in what is actually supposed to do when you set set serialization callback via Carbon::serializeUsing(). So unless you start digging into the framework yourself quite deeply, you'll have no chance predict how your dates are going to be serialized.
The general problem that makes it hard to find a good implementation is that it is quite tricky to make a good decision on when to use the global carbon serializer or the serialization format that results from the properties set on the eloquent instances. As far as I understand, at the moment the global serialization callback is not considered at all, if your models are serialized via the built-in eloquent mechanisms. The callback is only used when a carbon is serialized directly. Then $carbon->jsonSerialize is called, which uses the global serialization callback.
In my case I just added the following function overwrite to my base model:

protected function serializeDate(DateTimeInterface $date)
{
    return $date->jsonSerialize();
}

This is at least sufficient for me for now and in my specific case, but it will probably not meet a lot of other needs, because it's unflexible. At least this way you have a central point to define how you dates should be serialized. The internal eloquent date serialization is completely skipped.

UPDATE
I just realized that it does not work for dates in blade templates: {{$user->created_at}} is automatically serialized to the readable format Y-m-d H:i:s but I don't know where this comes from, since this is not the format I added via serializeUsing.
This whole stuff is really annoying and confusing.

So I've been dealing with this for a few hours and I came up with something that isn't too terrible imo.

My problem was related to adding timezones to the carbon instance and to the json output of models.

I'm not sure was serializeUsing was intended for, because it isn't used to serialize dates in ->toJson() ,but I figured it should.

AppServiceProvider.php

public function boot()
{
    Carbon::serializeUsing(function (Carbon $carbon) {
        return $carbon->format('Y-m-d H:i:s P');
    });
}

For the models I made a trait that overrides two methods.

Overriding the asDateTime method lets the carbon instance be modified so the changes are accessible in the code.

Overriding the serializeDate method makes the carbon instances use jsonSerialize() instead of the format provided by the models.

HasTimezone.php

namespace App\Traits;

use Illuminate\Support\Carbon;

/**
 * App\Traits\HasTimezone
 */
trait HasTimezone
{
    /**
     * Return a timestamp as DateTime object with a timezone.
     *
     * @param  mixed  $value
     * @return \Illuminate\Support\Carbon
     */
    protected function asDateTime($value)
    {
        $carbon = parent::asDateTime($value);
        return $carbon->tz(config('tenant.timezone'));
    }

    /**
     * Prepare a date for array / JSON serialization.
     *
     * @param  \DateTimeInterface  $date
     * @return string
     */
    protected function serializeDate(\DateTimeInterface $date)
    {
        return $date->jsonSerialize();
    }
}

Example

$t = \App\Ticket::first();
$t->created_at
=> Illuminate\Support\Carbon @1520959699 {#52
     date: 2018-03-13 12:48:19.0 America/New_York (-04:00),
   }

$t->toJson(JSON_PRETTY_PRINT)
=> """
   {\n
       ...
       "created_at": "2018-03-13 12:48:19 -04:00",\n
       "updated_at": "2018-04-06 12:48:20 -04:00",\n
   }
   """

This is working for me, for now. I'm sure I'll find something it breaks but I hope it helps others struggling with this.

Edit 4/10/2018: This wasn't serializing the relation carbon instance correctly, so I looked through the laravel framework code some more and decided to override serializeDate instead. Again, it doesn't seem to break anything.

When casting to an array, all date serialization is handled by HasAttributes->serializeDate

To cast dates to a timestamp integer on a single model:

    protected function serializeDate(\DateTimeInterface $date)
    {
        return intval($date->format('U'));
    }

The problems with Carbon::serializeUsing:

From what I see, the current implementation of Carbon::serializeUsing is only applied in jsonSerialize, and should thus be called Carbon::jsonSerializeUsing.

We could change serializeDate() to use $date->serialize(), but if serializeUsing() is not set, that will serialize the whole object while we just want to format the time. That is probably why HasAttributes->serializeDate is avoiding it.

Also, Carbon::$serializer is protected, so to make our serialization conditional, we need some extra tweaks to check it from Laravel.

With patch #26382, I can finally do this:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Carbon;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Carbon::serializeUsing(function ($carbon) {
            return intval($carbon->format('U'));
        });
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

Hi, I read this whole thread and it seems many different intents and expectations are mentioned. And all of them are not bugs, then some can yet be handled.

Cast to string such as {{$user->created_at}}

The easier one: {{$user->created_at}} the general case here is about implicit cast of the Carbon object into string. It does not rely to Laravel at all. It follows rules you can find in the Carbon documentation: https://carbon.nesbot.com/docs/#api-formatting

Here you should avoid to change globally the way Carbon objects are casted to strings, you should rather explicitly format dates in your templates according to users and app settings, the context of the page and so on: {{ $user->created_at->copy()->tz($userTimezone)->locale($userLanguage)->calendar() }} or choose a precise isoFormat/format then if you use the same formats in many places, create macros: https://carbon.nesbot.com/docs/#user-settings

->copy() allow you to reuse the original created_at later in your template.

@Dewbud it's also the better way to handle a given timezone rather than overriding asDateTime.

Carbon::serializeUsing

About Carbon::serializeUsing, indeed the name is not exact, it's inherited from the original method that existed before in Laravel if I remember correctly, it should be Carbon::jsonEncodeUsing because it's dedicated to json_encode conversions. So it suits for jsonSerialize and toJson, but not so well for toArray. This point should probably be adjusted in Laravel, because right now jsonSerialize = toArray, then while "2018-03-13T12:48:19Z" is a perfect match for a JSON output (as new Date() in JS will natively understand it), it's not for an SQL database or other APIs and so toArray should be less opinionated than jsonSerialize, but the current default format with no timezone ("2018-03-13 12:48:19") is still a good choice IMHO for the toArray output.

serializeDate

The serializeDate that can be overridden at model level has the same problem as above, it's for both toArray and jsonSerialize but as there is no particular reason to convert dates the same way for both, the tool we miss here is separated methods serializeDateForArray and serializeDateForJson. serializeDateForArray would use the existing default format ("2018-03-13 12:48:19"), while serializeDateForJson would use the serialization passed to Carbon::serializeUsing() then only if there is no custom serializer, then it could fallback to ->serializeDateForArray()

Solution

The solution would imply breaking change (additional parameter for toArray, attributesToArray and relationsToArray) so jsonSerialize could call toArray with a closure to be applied on each value handled recursively by toArray so custom output by type could be handled in this closure. This solution is the cleanest IMHO but as it's breaking, it has no chance to be implemented until Laravel v5.8/5.9. So until this we have to work around the issue:

Work-around

As @Dewbud suggested, a trait is a easy way to apply a custom serialization to the models you it in and it does not only apply to timezone, it applies to any formatting needed for JSON output, so I recommend the following trick:

namespace App\Traits;

use Illuminate\Support\Carbon;

/**
 * App\Traits\MyCustomJsonSerialization
 */
trait MyCustomJsonSerialization
{
    protected $jsonSerializationInProgress = false;

    /**
     * Prepare a date for array / JSON serialization.
     *
     * @param  \DateTimeInterface  $date
     * @return string
     */
    protected function serializeDate(\DateTimeInterface $date)
    {
        if ($this->jsonSerializationInProgress) {
            // Here you can return anything you need for JSON objects ($date->toJSON), timestamp, etc.
            // You can also change the timezone, set the locale to handle internationalization, etc.
            return $date->format('Y-m-d\TH:i:s');
        }

        return parent::serializeDate($date);
    }

    /**
     * Convert the object into something JSON serializable.
     *
     * @return array
     */
    protected function jsonSerialize(\DateTimeInterface $date)
    {
        $this->jsonSerializationInProgress = true;
        $array = $this->toArray();
        $this->jsonSerializationInProgress = false;

        return $array;
    }
}

I know such a boolean flag is not ideal avoid getting in a mess with toArray native usages and should provides a precise customization without having to change a thing in the framework code.

Hope it can help.

Just as another note, I've also run into this when building an API using JsonResource responses. I would have assumed one of the main use cases for global date formatting is for API requests and responses.

The default artisan make:resource Test command builds a JsonResource like the following:

class Test extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return parent::toArray($request); // created_at is formatted to string with incorrect date format
    }
}

Any dates here will be converted with strings with the default Laravel (in my case incorrect) formatting.

However if manually building the array they are instead formatted correctly!

class Test extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'created_at' => $this->created_at, // Now created_at is a carbon class and correct date format is honoured
        ];
    }
}

However it's pretty tedious to have to manually define every single field in the toArray() method when 99% of the time you probably just want to serialize the entire model.

My work-around:

class Test extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {

        $arr = parent::toArray($request);

        // toArray transforms the date attributes to strings, but with the wrong format!
        // Override dates here with a new carbon instance to honour global format in jsonSerialize()
        if ($this->resource->getDates()) {
            foreach ($this->resource->getDates() as $date_field) {
                $arr[$date_field] = $this->{$date_field};
            }
        }

        return $arr;
    }
}

Would be nice to see a proper solution to this in 5.8 :)

serializeUsing is now deprecated by Carbon so I'm closing this as it's no longer recommended to use this: https://github.com/briannesbitt/Carbon/issues/1870

In Laravel 7, a new default for date serialization will be used: https://github.com/laravel/framework/pull/30715

Note than a better replacement for serializeUsing could be:

Date::swap(new \Carbon\Factory([
    'toJsonFormat' => function ($date) {
        return $date->getTimestamp();
    },
]));

(or FactoryImmutable for immutable dates).

This will change only Date so Carbon instances created internally by Laravel. This one won't change globally every Carbon instances, so it won't break other libraries that use Carbon but you still have to check if none of your installed libraries related to Laravel has some json_encode($aLaravelDate) in its code.

Was this page helpful?
0 / 5 - 0 ratings