#PHP#Laravel#Carbon#createFromTimestamp

最近碰到 Laravel 11 的專案,突然發現 createFromTimestamp toDateTimeString 的結果變為 UTC+0。

<?php
use Carbon\Carbon;

$now = Carbon::now();

// 1745305106
$timestamp = $now->timestamp;

// 2025-04-22 14:58:26
$now->toDateTimeString();

// 2025-04-22 06:58:26
Carbon::createFromTimestamp($timestamp)->toDateTimeString();

立刻去檢查了 config/app.phptimezoneenv,發現確實有設定 APP_TIMEZONE=Asia/Taipei, 往下挖掘了新舊專案的底層 Carbon Source Code 後發現原來是 Laravel 11 已經將 Carbon 從 2 升級到 3 (https://laravel.com/docs/11.x/upgrade#carbon-3),而 Carbon 3 的 createFromTimestamp 如果不帶入 Timezone 將會預設使用 UTC+0,與 Carbon 2 如果不帶入 Timezone 將會抓取 date_default_timezone_get() 的方式大不相同。

查了一下 Github 上也有 Issues 討論: https://github.com/briannesbitt/Carbon/issues/2948

可以看到在這個 commit 中將如果為 null 預設抓取 date_default_timezone_get 的程式碼移除了。

- 	protected static function getDateTimeZoneNameFromMixed($timezone): string
+ 	protected static function getDateTimeZoneNameFromMixed(string|int|float $timezone): string
  	{
-		if ($timezone === null) {
-			return date_default_timezone_get();
-		}

所以未來要使用 createFromTimestamp 時就必須帶入 timezone 參數。

<?php
use Carbon\Carbon;

Carbon::createFromTimestamp($timestamp, config('app.timezone'));

即便官方文件後來有補上此說明,但因為 $timezone 參數可為 null,所以對 Unit Test 沒完整判斷 assertEquals的系統將會沒有症狀的出現 Bug,設為必填至少會有 ArgumentCountError ,個人覺得第二參數設為必填較佳。

<?php
/**
 * Create a Carbon instance from a timestamp and set the timezone (UTC by default).
 *
 * Timestamp input can be given as int, float or a string containing one or more numbers.
 */
public static function createFromTimestamp(
    float|int|string $timestamp,
    DateTimeZone|string|int|null $timezone = null,
): static {
    $date = static::createFromTimestampUTC($timestamp);

    return $timezone === null ? $date : $date->setTimezone($timezone);
}

Laravel CORS


#Laravel#PHP#CORS

CORS(Cross-Origin Resource Sharing,跨來源資源共享)是一種網頁安全機制,允許瀏覽器在不同來源(不同網域、協議或埠)之間安全地請求資源。它是 HTTP 標頭的一部分,能夠讓伺服器控制哪些來源可以存取它的資源,從而防止惡意網站發送未經授權的請求。

出於安全考量,瀏覽器內建了同源政策(Same-Origin Policy, SOP),限制網頁只能向相同來源的伺服器發送請求。如果網頁嘗試存取不同來源的資源(如 API、圖片、字型等),瀏覽器會阻擋這些請求。CORS 透過額外的 HTTP 標頭讓伺服器明確告訴瀏覽器「這些來源是被允許的」,從而放寬同源政策的限制。

如果後端沒有設定允許跨來源會收到 Http Status Code 200,但會是 CORS error。

Request URL: https://mtsung.com/api/members
Request Method: GET
Status Code: 200 OK
Referrer Policy: strict-origin-when-cross-origin

在 Laravel 9.2 前的版本必須自己安裝 fruitcake/laravel-cors ,在 Laravel 9.2 以後 Laravel 核心已包含 fruitcake/laravel-cors 了,可以直接使用以下指令產生 config/cors.php

php artisan config:publish cors

產生的 config/cors.php 如下

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Cross-Origin Resource Sharing (CORS) Configuration
    |--------------------------------------------------------------------------
    |
    | Here you may configure your settings for cross-origin resource sharing
    | or "CORS". This determines what cross-origin operations may execute
    | in web browsers. You are free to adjust these settings as needed.
    |
    | To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    |
    */

    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['*'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => false,

];

預設他會將 api/* 與 sanctum/csrf-cookie 加入 paths 中,如果要全部 route 都允許的話直接改成 ['*'] 即可

'paths' => ['*']

若要在純 PHP 專案中解決可以加上

<?php
// 前面不要有其他輸出
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: *');
header('Access-Control-Allow-Headers: *');

注意 header() 前不要有其他 echo 字元,否則瀏覽器會解析不到。


Laravel DB Listen


#PHP#Laravel#MySQL

Laravel ORM 很好用讓我們不用寫 SQL,但常常會需要看自己的 SQL 有無效能問題跟符合預期,會在 Builder 使用 toSql()dd(),Laravel 10 後的版本還有新增直接幫你把 bindings 帶入的 toRawSql() 和 ddRawSql() 讓開發者更方便觀看 ORM 的 SQL。

toSql()


User::query()->where('email', 'MTsung103035@gmail.com')->toSql();
// select * from `users` where `email` = ?

dd()


User::query()->where('email', 'MTsung103035@gmail.com')->dd();
// select * from `users` where `email` = ?
// ['MTsung103035@gmail.com']

toRawSql()


User::query()->where('email', 'MTsung103035@gmail.com')->toRawSql();
// select * from `users` where `email` = 'MTsung103035@gmail.com'

ddRawSql()


User::query()->where('email', 'MTsung103035@gmail.com')->ddRawSql();
// select * from `users` where `email` = 'MTsung103035@gmail.com'

但每次都要一個一個 toRawSql() 實在有點不方便,所以 Laravel 有提供 DB::listen 用來聆聽所有 DB 操作的 function

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    // $query->sql;
    // $query->bindings;
    // $query->time;
    // $query->toRawSql();
});

有了這個方法後就能加到 AppServiceProvider 讓每個 request 的 SQL 都紀錄起來,記得設定 env QUERY_LOG 只在開發環境中紀錄。

<?php
 
namespace App\Providers;
 
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
 
class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // ...
    }
 
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        if (env('QUERY_LOG', false)) {
            DB::listen(function ($query) {
                Log::channel('sql-query')->debug('', [
                    $query->toRawSql(),
                    $query->time . 'ms',
                ]);
            });
        }
    }
}

Log


[2025-03-28 08:07:07] develop.DEBUG:  ["select count(*) as aggregate from `member`","384.33ms"]
[2025-03-28 08:07:07] develop.DEBUG:  ["select * from `member` order by `id` desc limit 5 offset 0","81.03ms"]
[2025-03-28 08:07:07] develop.DEBUG:  ["select * from `member_creditcard` where `member_creditcard`.`member_id` in (1, 2, 3, 4, 5) and `member_creditcard`.`deleted_at` is null order by `is_default` desc, `id` desc","81.01ms"]
[2025-03-28 08:07:07] develop.DEBUG:  ["select `laravel_table`.*, @laravel_row := if(@laravel_group = `member_id`, @laravel_row + 1, 1) as `laravel_row`, @laravel_group := `member_id` from (select @laravel_row := 0, @laravel_group := 0) as `laravel_vars`, (select * from `task` where `task`.`member_id` in (1, 2, 3, 4, 5) order by `task`.`member_id` asc, `id` desc) as `laravel_table` having `laravel_row` <= 1 order by `laravel_row`","83.59ms"]


#PHP#Laravel#MySQL#Deferred Join

在 MySQL 中當資料表筆數過多時,使用 limit N offset M 查詢頁面越後面越久,因為 offset 會把 M 筆資料全都抓出來後再丟棄,造成 I/O 的資源浪費。

SELECT id, name FROM users ORDER BY id LIMIT 10 OFFSET 0 -- 很快
SELECT id, name FROM users ORDER BY id LIMIT 10 OFFSET 10000000 -- 超慢,會撈取 10000010 筆資料再丟棄前面的 10000000

解決方法有兩種,取決於需不需要跳頁(頁碼),如果服務是無限滾動的分頁方式,可以參考 Laravel 的 Cursor Pagination,他會使用主鍵(Primary Key)透過 Index 搜尋加速查詢。

SELECT id, name FROM users id > 10000000 ORDER BY id ASC LIMIT 10

若需要頁碼的話也是需要索引(Index)的幫助,先撈出需要的 id 再透過 WHERE IN 方式撈取。

SELECT id FROM users ORDER BY id LIMIT 10 OFFSET 10000000 -- 很快,因為只有用到 PK,不需回表查詢(Lookup)
SELECT id, name FROM users id IN (
	SELECT id FROM users ORDER BY id LIMIT 10 OFFSET 10000000
)

但這麼做會有可能因為 MySQL 設定出現

#1235 - This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'

解決方法有兩種

1. 拆成兩個 Query,先將 ids 取出後再 WHERE IN 

SELECT id FROM users ORDER BY id LIMIT 10 OFFSET 10000000;

SELECT id, name FROM users WHERE id IN (10000001, 10000002, 10000003, 10000004, 10000005, 10000006, 10000007, 10000008, 10000009, 10000010);
<?php 

// Simple Paginate
$ids = collect(Users::query()->select('id')->orderBy('id')->simplePaginate(10)->items())->pluck('id');

// Custom Offset and Limit
$ids = Users::query()->select('id')->orderBy('id')->offset(10000000)->limit(10)->get();

$data = Users::query()->select(['id', 'name'])->whereIn('id', $ids)->get();

2. Deferred Join - 自己 JOIN 自己

SELECT users.id, users.name FROM (
	SELECT id FROM users ORDER BY id LIMIT 10 OFFSET 10000000
) AS index_users
JOIN users ON users.id = index_users.id
<?php 

$subQuery = User::query()->select('id')->orderBy('id')->offset(10000000)->limit(10);

$data = User::query()
	->select('users.id', 'users.name')
	->rightJoinSub($subQuery, 'index_users', function ($join) {
        $join->on('users.id', '=', 'index_users.id');
    })
    ->get();

當然這方式還是有極限,真的還是很慢可能就要考慮分片(Sharding)了。


Laravel 在不同專案共用 Redis


#Laravel#Redis

連接同一台 Redis 後,只要將兩個專案 evn 中 REDIS_PREFIX、CACHE_PREFIX 分別設定同一個值即可。
Laravel Redis Cache Key 會使用 REDIS_PREFIX、CACHE_PREFIX 組合出 Key。

Redis Cli

127.0.0.1:6379> SELECT 1
OK
127.0.0.1:6379[1]> KEYS *
 1) "app_name_database_app_name_cache_:cache-key-1"
 2) "app_name_database_app_name_cache_:cache-key-2" 

config/database.php

<?php

use Illuminate\Support\Str;

return [

	// 略...

	'redis' => [

	    'client' => env('REDIS_CLIENT', 'phpredis'),

	    'options' => [
	        'cluster' => env('REDIS_CLUSTER', 'redis'),
	        'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
	    ],
	],


	// 略...
];

config/cache.php

<?php

use Illuminate\Support\Str;

return [

	// 略...

	'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),

];

    前往