├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Casts │ ├── ImmutableTimezonedDatetime.php │ └── TimezonedDatetime.php ├── Concerns │ └── HasTimezonedTimestamps.php ├── DatetimeParser.php ├── Facades │ └── Timezone.php ├── ServiceProvider.php └── Timezone.php └── tests ├── Pest.php ├── TestCase.php └── Unit ├── CastTest.php └── TimezoneSingletonTest.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: whitecube -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | .idea 3 | .DS_Store 4 | Thumbs.db 5 | composer.lock 6 | .phpunit.cache 7 | .swp 8 | *.map -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Whitecube 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Timezones 2 | 3 | Dealing with timezones can be a frustrating experience. Here's an attempt to brighten your day. 4 | 5 | **The problem:** it is commonly agreed that dates should be stored as `UTC` datetimes in the database, which generally means they also need to be adapted for the local timezone before manipulation or display. Laravel provides a `app.timezone` configuration, making it possible to start working with timezones. However, changing that configuration will affect both the stored and manipulated date's timezones. This package tries to address this by providing a timezone conversion mechanism that should perform most of the repetitive timezone configurations out of the box. 6 | 7 | ```php 8 | // Model: 9 | protected $casts = [ 10 | 'occurred_at' => TimezonedDatetime::class, 11 | ]; 12 | 13 | // Set a custom timezone 14 | Timezone::set('Europe/Brussels'); 15 | 16 | // Display dates stored as UTC in the app's timezone: 17 | // (database value: 2022-12-13 09:00:00) 18 | echo $model->occurred_at->format('d.m.Y H:i'); // Output: 13.12.2022 10:00 19 | 20 | // Store dates using automatic UTC conversion: 21 | $model->occurred_at = '2022-12-13 20:00:00'; 22 | $model->save(); // Database value: 2022-12-13 19:00:00 23 | ``` 24 | 25 | ## Installation 26 | 27 | ```bash 28 | composer require whitecube/laravel-timezones 29 | ``` 30 | 31 | ## Getting started 32 | 33 | The `app.timezone` configuration setting has to be set to the timezone that should be used when saving dates in the database. We highly recommend keeping it as `UTC` since it's a global standard for dates storage. 34 | 35 | For in-app date manipulation and display, one would expect more flexibility. That's why it is possible to set the application's timezone dynamically by updating the `timezone` singleton instance. Depending on the app's context, please choose one that suits your situation best: 36 | 37 | ### 1. Using middleware 38 | 39 | Useful when the app's timezone should be set by ther user's settings. 40 | 41 | ```php 42 | namespace App\Http\Middleware; 43 | 44 | use Closure; 45 | use Whitecube\LaravelTimezones\Facades\Timezone; 46 | 47 | class DefineApplicationTimezone 48 | { 49 | public function handle($request, Closure $next) 50 | { 51 | Timezone::set($request->user()->timezone ?? 'Europe/Brussels'); 52 | 53 | return $next($request); 54 | } 55 | } 56 | ``` 57 | 58 | ### 2. Using a Service Provider 59 | 60 | Useful when the app's timezone should be set by the application itself. For instance, in `App\Providers\AppServiceProvider`: 61 | 62 | ```php 63 | namespace App\Providers; 64 | 65 | use Illuminate\Support\ServiceProvider; 66 | use Whitecube\LaravelTimezones\Facades\Timezone; 67 | 68 | class AppServiceProvider extends ServiceProvider 69 | { 70 | public function boot() 71 | { 72 | Timezone::set('America/Toronto'); 73 | } 74 | } 75 | ``` 76 | 77 | ## Usage 78 | 79 | Once everything's setup, the easiest way to manipulate dates configured with the app's current timezone is to use the `TimezonedDatetime` or `ImmutableTimezonedDatetime` cast types on your models: 80 | 81 | ```php 82 | use Whitecube\LaravelTimezones\Casts\TimezonedDatetime; 83 | use Whitecube\LaravelTimezones\Casts\ImmutableTimezonedDatetime; 84 | 85 | /** 86 | * The attributes that should be cast. 87 | * 88 | * @var array 89 | */ 90 | protected $casts = [ 91 | 'published_at' => TimezonedDatetime::class, 92 | 'birthday' => ImmutableTimezonedDatetime::class . ':Y-m-d', 93 | ]; 94 | ``` 95 | 96 | In other scenarios, feel free to use the `Timezone` Facade directly for more convenience: 97 | 98 | ```php 99 | use Carbon\Carbon; 100 | use Whitecube\LaravelTimezones\Facades\Timezone; 101 | 102 | // Get the current date configured with the current timezone: 103 | $now = Timezone::now(); 104 | 105 | // Create a date using the current timezone: 106 | $date = Timezone::date('2023-01-01 00:00:00'); 107 | // Alternatively, set the timezone manually on a Carbon instance: 108 | $date = new Carbon('2023-01-01 00:00:00', Timezone::current()); 109 | 110 | 111 | // Convert a date to the current timezone: 112 | $date = Timezone::date(new Carbon('2023-01-01 00:00:00', 'UTC')); 113 | // Alternatively, set the application timezone yourself: 114 | $date = (new Carbon('2023-01-01 00:00:00', 'UTC'))->setTimezone(Timezone::current()); 115 | 116 | // Convert a date to the storage timezone: 117 | $date = Timezone::store(new Carbon('2023-01-01 00:00:00', 'Europe/Brussels')); 118 | // Alternatively, set the storage timezone yourself: 119 | $date = (new Carbon('2023-01-01 00:00:00', 'Europe/Brussels'))->setTimezone(Timezone::storage()); 120 | ``` 121 | 122 | ## Assigning values to cast attributes 123 | 124 | Many developers are used to assign Carbon instances to date attributes: 125 | 126 | ```php 127 | $model->published_at = Carbon::create($request->published_at); 128 | ``` 129 | 130 | **This can lead to unexpected behavior** because the assigned Carbon instance will default to the `UTC` timezone, whereas the provided value was probably meant for another timezone. The datetime string will be stored as-is without shifting its timezone accordingly first. 131 | 132 | In order to prevent this, it is recommended to let the Cast do the heavy lifting: 133 | 134 | ```php 135 | $model->published_at = $request->published_at; 136 | ``` 137 | 138 | The package will now treat the provided datetime string using the correct Timezone (for instance, `Europe/Brussels`) and store the shifted `UTC` value in the database correctly. 139 | 140 | A more verbose (but also correct) method would be to create the Carbon instance using the `Timezone` facade : 141 | 142 | ```php 143 | $model->published_at = Carbon::create($request->published_at, Timezone::current()); 144 | // Or, shorthand: 145 | $model->published_at = Timezone::date($request->published_at); 146 | ``` 147 | 148 | **This is not a bug**, it is intended behavior since one should be fully aware of the Carbon instance's timezone before assigning it. 149 | 150 | ### Edge cases 151 | 152 | If you need to use the `TimezonedDatetime` or `ImmutableTimezonedDatetime` casts on the default timestamp columns (`created_at` and/or `updated_at`) AND you're expecting to handle dates with timezones other than UTC or the one you've defined with `Timezone::set()`, you will need to apply the `Whitecube\LaravelTimezones\Concerns\HasTimezonedTimestamps` trait on your model. 153 | 154 | This is necessary to prevent Laravel's casting of those attributes to occur, which would transform the value in a way where the timezone information is lost, preventing our cast from working properly. 155 | 156 | An example of a case where you need to use the trait: 157 | 158 | ```php 159 | Timezone::set('Europe/Brussels'); 160 | 161 | $model->created_at = new Carbon('2022-12-15 09:00:00', 'Asia/Taipei'); 162 | ``` 163 | 164 | 165 | ## 🔥 Sponsorships 166 | 167 | If you are reliant on this package in your production applications, consider [sponsoring us](https://github.com/sponsors/whitecube)! It is the best way to help us keep doing what we love to do: making great open source software. 168 | 169 | ## Contributing 170 | 171 | Feel free to suggest changes, ask for new features or fix bugs yourself. We're sure there are still a lot of improvements that could be made, and we would be very happy to merge useful pull requests. Thanks! 172 | 173 | ## Made with ❤️ for open source 174 | 175 | At [Whitecube](https://www.whitecube.be) we use a lot of open source software as part of our daily work. 176 | So when we have an opportunity to give something back, we're super excited! 177 | 178 | We hope you will enjoy this small contribution from us and would love to [hear from you](mailto:hello@whitecube.be) if you find it useful in your projects. Follow us on [Twitter](https://twitter.com/whitecube_be) for more updates! 179 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitecube/laravel-timezones", 3 | "description": "Store UTC dates in the database and work with custom timezones in the application.", 4 | "keywords": [ 5 | "whitecube", 6 | "laravel", 7 | "utc", 8 | "local", 9 | "time", 10 | "conversion" 11 | ], 12 | "type": "library", 13 | "license": "MIT", 14 | "autoload": { 15 | "psr-4": { 16 | "Whitecube\\LaravelTimezones\\": "src/" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "Whitecube\\LaravelTimezones\\Tests\\": "tests/" 22 | } 23 | }, 24 | "authors": [ 25 | { 26 | "name": "Toon Van den Bos", 27 | "email": "toon@whitecube.be" 28 | } 29 | ], 30 | "require": { 31 | "php": ">=8.1", 32 | "laravel/framework": "^9|^10|^11|^12.0", 33 | "nesbot/carbon": "^2.64|^3.0" 34 | }, 35 | "require-dev": { 36 | "mockery/mockery": "^1.6", 37 | "pestphp/pest": "^2.34|^3.7" 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "Whitecube\\LaravelTimezones\\ServiceProvider" 43 | ] 44 | } 45 | }, 46 | "config": { 47 | "allow-plugins": { 48 | "pestphp/pest-plugin": true 49 | } 50 | }, 51 | "minimum-stability": "dev", 52 | "prefer-stable": true 53 | } 54 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Casts/ImmutableTimezonedDatetime.php: -------------------------------------------------------------------------------- 1 | toImmutable(); 16 | } 17 | 18 | return $date; 19 | } 20 | } -------------------------------------------------------------------------------- /src/Casts/TimezonedDatetime.php: -------------------------------------------------------------------------------- 1 | format = $format; 28 | } 29 | 30 | /** 31 | * Cast the given value. 32 | * 33 | * @return \Carbon\CarbonInterface 34 | */ 35 | public function get(Model $model, string $key, mixed $value, array $attributes) 36 | { 37 | if(!$value && $value !== 0) { 38 | return null; 39 | } 40 | 41 | if ($this->isTimestamp($model, $key)) { 42 | $value = Carbon::parse($value)->format($this->format ?? $model->getDateFormat()); 43 | } 44 | 45 | $original = Timezone::store($value, fn($raw, $tz) => $this->asDateTime($raw, $tz, $model)); 46 | 47 | return Timezone::date($original); 48 | } 49 | 50 | /** 51 | * Prepare the given value for storage. 52 | * 53 | * @return string 54 | */ 55 | public function set(Model $model, string $key, mixed $value, array $attributes) 56 | { 57 | if(!$value && $value !== 0) { 58 | return null; 59 | } 60 | 61 | if ($this->isTimestamp($model, $key) && is_string($value)) { 62 | $value = Carbon::parse($value, Config::get('app.timezone')); 63 | } 64 | 65 | $requested = Timezone::date($value, fn($raw, $tz) => $this->asDateTime($raw, $tz, $model)); 66 | 67 | return Timezone::store($requested)->format($this->format ?? $model->getDateFormat()); 68 | } 69 | 70 | /** 71 | * Check if the given key is part of the model's known timestamps. 72 | */ 73 | protected function isTimestamp(Model $model, string $key): bool 74 | { 75 | return $model->usesTimestamps() && in_array($key, $model->getDates()); 76 | } 77 | 78 | /** 79 | * Create a new date value from raw material. 80 | */ 81 | public function asDateTime(mixed $value, CarbonTimeZone $timezone, Model $model): CarbonInterface 82 | { 83 | $date = (new DatetimeParser)->parse($value, $this->format ?? $model->getDateFormat()); 84 | 85 | if ($this->hasTimezone($value)) { 86 | return $date->setTimezone($timezone); 87 | } 88 | 89 | return $date->shiftTimezone($timezone); 90 | } 91 | 92 | /** 93 | * Check if the provided value contains timezone information. 94 | */ 95 | protected function hasTimezone(mixed $value): bool 96 | { 97 | return (is_string($value) && array_key_exists('zone', date_parse($value))) 98 | || (is_a($value, DateTime::class) && $value->getTimezone()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Concerns/HasTimezonedTimestamps.php: -------------------------------------------------------------------------------- 1 | getDates(), true) || 16 | $this->isDateCastable($key)) && 17 | ! $this->hasTimezonedDatetimeCast($key); 18 | } 19 | 20 | /** 21 | * Check if key is a timezoned datetime cast. 22 | */ 23 | protected function hasTimezonedDatetimeCast(string $key): bool 24 | { 25 | $cast = $this->getCasts()[$key] ?? null; 26 | 27 | if (! $cast) { 28 | return false; 29 | } 30 | 31 | $castClassName = explode(':', $cast)[0]; 32 | 33 | return in_array( 34 | $castClassName, 35 | [TimezonedDatetime::class, ImmutableTimezonedDatetime::class] 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/DatetimeParser.php: -------------------------------------------------------------------------------- 1 | format = $format; 23 | 24 | return $this->asDateTime($value); 25 | } 26 | 27 | /** 28 | * Get the format for database stored dates. 29 | */ 30 | public function getDateFormat(): ?string 31 | { 32 | return $this->format; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Facades/Timezone.php: -------------------------------------------------------------------------------- 1 | app->singleton(Timezone::class, function ($app) { 16 | return new Timezone(config('app.timezone')); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Timezone.php: -------------------------------------------------------------------------------- 1 | setStorage($default); 33 | $this->setCurrent($default); 34 | } 35 | 36 | /** 37 | * @alias setCurrent 38 | * 39 | * Set the current application timezone. 40 | */ 41 | public function set(mixed $timezone = null): void 42 | { 43 | $this->setCurrent($timezone); 44 | } 45 | 46 | /** 47 | * Set the current application timezone. 48 | */ 49 | public function setCurrent(mixed $timezone): void 50 | { 51 | $this->current = $this->makeTimezone($timezone); 52 | } 53 | 54 | /** 55 | * Return the current application timezone. 56 | */ 57 | public function current(): CarbonTimeZone 58 | { 59 | return $this->current; 60 | } 61 | 62 | /** 63 | * Set the current database timezone. 64 | */ 65 | public function setStorage(mixed $timezone): void 66 | { 67 | $this->storage = $this->makeTimezone($timezone); 68 | } 69 | 70 | /** 71 | * Return the current application timezone. 72 | */ 73 | public function storage(): CarbonTimeZone 74 | { 75 | return $this->storage; 76 | } 77 | 78 | /** 79 | * Get the current timezoned date. 80 | */ 81 | public function now(): CarbonInterface 82 | { 83 | return $this->convertToCurrent(Date::now()); 84 | } 85 | 86 | /** 87 | * Configure given date for the application's current timezone. 88 | */ 89 | public function date(mixed $value, ?callable $maker = null): CarbonInterface 90 | { 91 | return $this->makeDateWithCurrent($value, $maker); 92 | } 93 | 94 | /** 95 | * Configure given date for the database storage timezone. 96 | */ 97 | public function store(mixed $value, ?callable $maker = null): CarbonInterface 98 | { 99 | return $this->makeDateWithStorage($value, $maker); 100 | } 101 | 102 | /** 103 | * Duplicate the given date and shift its timezone to the application's current timezone. 104 | */ 105 | protected function convertToCurrent(CarbonInterface $date): CarbonInterface 106 | { 107 | return $date->copy()->setTimezone($this->current()); 108 | } 109 | 110 | /** 111 | * Duplicate the given date and shift its timezone to the database's storage timezone. 112 | */ 113 | protected function convertToStorage(CarbonInterface $date): CarbonInterface 114 | { 115 | return $date->copy()->setTimezone($this->storage()); 116 | } 117 | 118 | /** 119 | * Create or configure date using the application's current timezone. 120 | */ 121 | protected function makeDateWithCurrent(mixed $value, ?callable $maker = null): CarbonInterface 122 | { 123 | return is_a($value, CarbonInterface::class) 124 | ? $this->convertToCurrent($value) 125 | : $this->makeDate($value, $this->current(), $maker); 126 | } 127 | 128 | /** 129 | * Create or configure date using the database's storage timezone. 130 | */ 131 | protected function makeDateWithStorage(mixed $value, ?callable $maker = null): CarbonInterface 132 | { 133 | return is_a($value, CarbonInterface::class) 134 | ? $this->convertToStorage($value) 135 | : $this->makeDate($value, $this->storage(), $maker); 136 | } 137 | 138 | /** 139 | * Create a date using the provided timezone. 140 | */ 141 | protected function makeDate(mixed $value, CarbonTimeZone $timezone, ?callable $maker = null): CarbonInterface 142 | { 143 | return ($maker) 144 | ? call_user_func($maker, $value, $timezone) 145 | : Date::create($value, $timezone); 146 | } 147 | 148 | /** 149 | * Create a Carbon timezone from given value. 150 | */ 151 | protected function makeTimezone(mixed $value): CarbonTimeZone 152 | { 153 | if(! is_a($value, CarbonTimeZone::class)) { 154 | $value = new CarbonTimeZone($value); 155 | } 156 | 157 | return $value; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Unit'); 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Expectations 25 | |-------------------------------------------------------------------------- 26 | | 27 | | When you're writing tests, you often need to check that values meet certain conditions. The 28 | | "expect()" function gives you access to a set of "expectations" methods that you can use 29 | | to assert different things. Of course, you may extend the Expectation API at any time. 30 | | 31 | */ 32 | 33 | // expect()->extend('toBeOne', function () { 34 | // return $this->toBe(1); 35 | // }); 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Functions 40 | |-------------------------------------------------------------------------- 41 | | 42 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 43 | | project that you don't want to repeat in every file. Here you can also expose helpers as 44 | | global functions to help you to reduce the number of lines of code in your test files. 45 | | 46 | */ 47 | 48 | function setupFacade(string $storage = 'UTC', string $current = 'Europe/Brussels') 49 | { 50 | $instance = new Timezone($storage); 51 | $instance->set($current); 52 | 53 | Facade::swap($instance); 54 | } 55 | 56 | function fakeModel() 57 | { 58 | return new class() extends Model { 59 | public function getDateFormat() 60 | { 61 | return 'Y-m-d H:i:s'; 62 | } 63 | }; 64 | } 65 | 66 | function fakeModelWithCast() 67 | { 68 | return new class() extends Model { 69 | use HasTimezonedTimestamps; 70 | 71 | protected $casts = [ 72 | 'test_at' => TimezonedDatetime::class, 73 | 'created_at' => TimezonedDatetime::class, 74 | 'updated_at' => TimezonedDatetime::class, 75 | ]; 76 | 77 | public function getDateFormat() 78 | { 79 | return 'Y-m-d H:i:s'; 80 | } 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | get(fakeModel(), 'id', $input, []); 15 | 16 | expect($output->getTimezone()->getName())->toBe('Europe/Brussels'); 17 | expect($output->format('Y-m-d H:i:s'))->toBe('2022-12-15 10:00:00'); 18 | }); 19 | 20 | it('can access UTC database date with application timezone and specific format', function() { 21 | setupFacade(); 22 | 23 | $cast = new TimezonedDatetime('d/m/Y H:i'); 24 | 25 | $input = '15/12/2022 09:00'; 26 | 27 | $output = $cast->get(fakeModel(), 'id', $input, []); 28 | 29 | expect($output->getTimezone()->getName())->toBe('Europe/Brussels'); 30 | expect($output->format('Y-m-d H:i:s'))->toBe('2022-12-15 10:00:00'); 31 | }); 32 | 33 | it('can access NULL date as NULL', function() { 34 | setupFacade(); 35 | 36 | $cast = new TimezonedDatetime(); 37 | 38 | $input = null; 39 | 40 | $output = $cast->get(fakeModel(), 'id', $input, []); 41 | 42 | expect($output)->toBeNull(); 43 | }); 44 | 45 | it('can access empty string as NULL', function() { 46 | setupFacade(); 47 | 48 | $cast = new TimezonedDatetime(); 49 | 50 | $input = ''; 51 | 52 | $output = $cast->get(fakeModel(), 'id', $input, []); 53 | 54 | expect($output)->toBeNull(); 55 | }); 56 | 57 | it('can mutate application timezone datetime string to UTC database date string', function() { 58 | setupFacade(); 59 | 60 | $cast = new TimezonedDatetime(); 61 | 62 | $input = '2022-12-15 10:00:00'; 63 | 64 | $output = $cast->set(fakeModel(), 'id', $input, []); 65 | 66 | expect($output)->toBe('2022-12-15 09:00:00'); 67 | }); 68 | 69 | it('can mutate application timezone date instance to UTC database date string', function() { 70 | setupFacade(); 71 | 72 | $cast = new TimezonedDatetime(); 73 | 74 | $input = new \Carbon\Carbon('2022-12-15 10:00:00', 'Europe/Brussels'); 75 | 76 | $output = $cast->set(fakeModel(), 'id', $input, []); 77 | 78 | expect($output)->toBe('2022-12-15 09:00:00'); 79 | }); 80 | 81 | it('can mutate UTC date instance to UTC database date string', function() { 82 | setupFacade(); 83 | 84 | $cast = new TimezonedDatetime(); 85 | 86 | $input = new \Carbon\Carbon('2022-12-15 09:00:00', 'UTC'); 87 | 88 | $output = $cast->set(fakeModel(), 'id', $input, []); 89 | 90 | expect($output)->toBe('2022-12-15 09:00:00'); 91 | }); 92 | 93 | it('can mutate date instance with exotic timezone to UTC database date string', function() { 94 | setupFacade(); 95 | 96 | $cast = new TimezonedDatetime(); 97 | 98 | $input = new \Carbon\Carbon('2022-12-15 04:00:00', 'America/Toronto'); 99 | 100 | $output = $cast->set(fakeModel(), 'id', $input, []); 101 | 102 | expect($output)->toBe('2022-12-15 09:00:00'); 103 | }); 104 | 105 | it('can mutate NULL as NULL', function() { 106 | setupFacade(); 107 | 108 | $cast = new TimezonedDatetime(); 109 | 110 | $input = null; 111 | 112 | $output = $cast->set(fakeModel(), 'id', $input, []); 113 | 114 | expect($output)->toBeNull(); 115 | }); 116 | 117 | it('can mutate empty string as NULL', function() { 118 | setupFacade(); 119 | 120 | $cast = new TimezonedDatetime(); 121 | 122 | $input = ''; 123 | 124 | $output = $cast->set(fakeModel(), 'id', $input, []); 125 | 126 | expect($output)->toBeNull(); 127 | }); 128 | 129 | 130 | it('can mutate 0 values', function() { 131 | // 4 hours difference between dubai and UTC 132 | setupFacade(current: 'Asia/Dubai'); 133 | 134 | $cast = new TimezonedDatetime('H'); 135 | 136 | $input = 0; 137 | 138 | $output = $cast->set(fakeModel(), 'id', $input, []); 139 | expect($output)->toEqual(20); 140 | 141 | $output = $cast->get(fakeModel(), 'id', $input, []); 142 | expect($output->format('H'))->toEqual(4); 143 | }); 144 | 145 | test('a model with a timezone date cast can be json serialized', function () { 146 | setupFacade(); 147 | 148 | Config::shouldReceive('get') 149 | ->with('app.timezone') 150 | ->andReturn('UTC'); 151 | 152 | $date = new Carbon('2022-12-15 09:00:00', 'UTC'); 153 | $model = fakeModelWithCast(); 154 | 155 | $model->test_at = $date; 156 | $model->updated_at = $date; 157 | 158 | expect($model->jsonSerialize()) 159 | ->toBe([ 160 | 'test_at' => $date->toJSON(), 161 | 'updated_at' => $date->toJSON() 162 | ]); 163 | }); 164 | 165 | test('a model with a timezone date cast can parse ISO-formatted values properly', function () { 166 | setupFacade(); 167 | 168 | Config::shouldReceive('get') 169 | ->with('app.timezone') 170 | ->andReturn('UTC'); 171 | 172 | $date = new Carbon('2022-12-15 09:00:00', 'UTC'); 173 | $model = fakeModelWithCast(); 174 | 175 | $model->test_at = $date->toIso8601String(); 176 | $model->updated_at = $date->toIso8601String(); 177 | 178 | expect($model->jsonSerialize()) 179 | ->toBe([ 180 | 'test_at' => $date->toJSON(), 181 | 'updated_at' => $date->toJSON() 182 | ]); 183 | }); 184 | 185 | test('a model with a timezone date cast can parse datetime values properly', function () { 186 | setupFacade(); 187 | 188 | Config::shouldReceive('get') 189 | ->with('app.timezone') 190 | ->andReturn('UTC'); 191 | 192 | $date = new DateTime('2022-12-15 09:00:00'); 193 | $model = fakeModelWithCast(); 194 | 195 | $model->test_at = $date; 196 | $model->updated_at = $date; 197 | 198 | expect($model->jsonSerialize()) 199 | ->toBe([ 200 | 'test_at' => '2022-12-15T09:00:00.000000Z', 201 | 'updated_at' => '2022-12-15T09:00:00.000000Z' 202 | ]); 203 | }); 204 | 205 | test('a model with a timezone date cast can parse datetime values with a defined timezone properly', function () { 206 | setupFacade(); 207 | 208 | Config::shouldReceive('get') 209 | ->with('app.timezone') 210 | ->andReturn('UTC'); 211 | 212 | $date = new DateTime('2022-12-15 09:00:00', new DateTimeZone('Asia/Taipei')); 213 | $model = fakeModelWithCast(); 214 | 215 | $model->test_at = $date; 216 | $model->updated_at = $date; 217 | 218 | expect($model->jsonSerialize()) 219 | ->toBe([ 220 | 'test_at' => '2022-12-15T01:00:00.000000Z', 221 | 'updated_at' => '2022-12-15T01:00:00.000000Z' 222 | ]); 223 | }); 224 | -------------------------------------------------------------------------------- /tests/Unit/TimezoneSingletonTest.php: -------------------------------------------------------------------------------- 1 | current())->toBeInstanceOf(CarbonTimeZone::class); 13 | expect($instance->storage())->toBeInstanceOf(CarbonTimeZone::class); 14 | }); 15 | 16 | it('can set the application timezone', function() { 17 | $instance = new Timezone('UTC'); 18 | 19 | expect($instance->current()->getName())->toBe('UTC'); 20 | 21 | $instance->set('Europe/Brussels'); 22 | 23 | expect($instance->current()->getName())->toBe('Europe/Brussels'); 24 | 25 | $instance->setCurrent('Europe/Paris'); 26 | 27 | expect($instance->current()->getName())->toBe('Europe/Paris'); 28 | }); 29 | 30 | it('can set the database timezone', function() { 31 | $instance = new Timezone('UTC'); 32 | 33 | expect($instance->storage()->getName())->toBe('UTC'); 34 | 35 | $instance->setStorage('Europe/Brussels'); 36 | 37 | expect($instance->storage()->getName())->toBe('Europe/Brussels'); 38 | }); 39 | 40 | it('can get the current date using the application\'s timezone', function() { 41 | $instance = new Timezone('UTC'); 42 | $instance->set('Europe/Brussels'); 43 | 44 | $date = $instance->now(); 45 | 46 | expect($date)->toBeInstanceOf(CarbonInterface::class); 47 | expect($date->getTimezone()->getName())->toBe('Europe/Brussels'); 48 | }); 49 | 50 | it('can create or convert a date using the application\'s current timezone', function() { 51 | $instance = new Timezone('UTC'); 52 | $instance->set('Europe/Brussels'); 53 | 54 | $string = $instance->date('1993-03-16 03:00:00'); 55 | $date = $instance->date(new Carbon('1993-03-16 02:00:00', 'UTC')); 56 | $custom = $instance->date('1993-03-16 03:00:00', fn($value, $tz) => new CarbonImmutable($value, $tz)); 57 | 58 | expect($string)->toBeInstanceOf(CarbonInterface::class); 59 | expect($string->getTimezone()->getName() ?? null)->toBe('Europe/Brussels'); 60 | expect($string->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 61 | expect($date)->toBeInstanceOf(CarbonInterface::class); 62 | expect($date->getTimezone()->getName() ?? null)->toBe('Europe/Brussels'); 63 | expect($date->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 64 | expect($custom)->toBeInstanceOf(CarbonImmutable::class); 65 | expect($custom->getTimezone()->getName() ?? null)->toBe('Europe/Brussels'); 66 | expect($custom->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 67 | }); 68 | 69 | 70 | it('can create or convert a date using the database\'s storage timezone', function() { 71 | $instance = new Timezone('UTC'); 72 | $instance->setCurrent('Europe/Brussels'); 73 | 74 | $string = $instance->store('1993-03-16 03:00:00'); 75 | $date = $instance->store(new Carbon('1993-03-16 04:00:00', 'Europe/Brussels')); 76 | $custom = $instance->store('1993-03-16 03:00:00', fn($value, $tz) => new CarbonImmutable($value, $tz)); 77 | 78 | expect($string)->toBeInstanceOf(CarbonInterface::class); 79 | expect($string->getTimezone()->getName() ?? null)->toBe('UTC'); 80 | expect($string->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 81 | expect($date)->toBeInstanceOf(CarbonInterface::class); 82 | expect($date->getTimezone()->getName() ?? null)->toBe('UTC'); 83 | expect($date->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 84 | expect($custom)->toBeInstanceOf(CarbonImmutable::class); 85 | expect($custom->getTimezone()->getName() ?? null)->toBe('UTC'); 86 | expect($custom->format('Y-m-d H:i:s'))->toBe('1993-03-16 03:00:00'); 87 | }); 88 | 89 | --------------------------------------------------------------------------------