會查資料只是一半,真正會出事的通常在寫入那一半:批量賦值沒設好被偷塞欄位、多張表寫到一半失敗留下髒資料、從資料庫撈出來的 0/1 忘了轉成布林值。今天把 Eloquent 的寫入操作、Transaction、屬性轉型一次補齊,也是這系列第一次遇到份量吃重的版本更新。
Day 9 建好了 Post/User 的資料結構,Day 10 學會怎麼查資料。今天換個方向:怎麼寫入、更新、刪除資料,再補上屬性轉型(Attribute Casting)這塊。這也是這系列第一個份量吃重的版本更新:Laravel 11 新增的 casts() 方法、Laravel 12 起 HasUuids 預設行為的改變。
// create():一次帶入陣列建立並儲存
$post = Post::create([
'user_id' => $userId,
'title' => 'Laravel 13 上手筆記',
'body' => '今天來聊聊 Eloquent 進階...',
]);
// firstOrCreate():找不到符合條件的資料才新增,避免重複建立
$post = Post::firstOrCreate(
['title' => 'Laravel 13 上手筆記'],
['user_id' => $userId, 'body' => '...']
);
// updateOrCreate():找到就更新,找不到就新增
$post = Post::updateOrCreate(
['user_id' => $userId, 'title' => '每日精選'],
['body' => '今天更新的內容...']
);
要注意 create() 能直接接受陣列,前提是 Model 有設定 $fillable(白名單)或 $guarded(黑名單),這是 Laravel 的「批量賦值保護(Mass Assignment Protection)」機制,避免使用者從表單偷塞你沒預期的欄位(例如偷改 is_admin):
// app/Models/Post.php
protected $fillable = ['user_id', 'title', 'body', 'is_featured', 'published_at'];
如果你要一次寫入大量資料(例如 Day 29 匯入舊資料),逐筆呼叫 create() 效率不佳,因為每次呼叫都是一個獨立的 SQL 語句、也各自觸發一次 Model 事件:
// insert:一次組成一個 SQL INSERT 語句,效能最好,但不會觸發任何 Model 事件、不會回傳建立好的 Model 實例
Post::insert([
['user_id' => 1, 'title' => 'A', 'slug' => 'a', 'body' => '...', 'created_at' => now(), 'updated_at' => now()],
['user_id' => 1, 'title' => 'B', 'slug' => 'b', 'body' => '...', 'created_at' => now(), 'updated_at' => now()],
]);
// upsert:存在就更新、不存在就新增,一次處理一批資料
Post::upsert(
[
['slug' => 'laravel-13-notes', 'title' => '更新後的標題', 'body' => '...', 'user_id' => 1],
],
uniqueBy: ['slug'], // 用哪個欄位判斷「這筆資料算不算已存在」
update: ['title', 'body'], // 已存在時,只更新這幾個欄位
);
upsert() 在同步外部資料源(例如 Day 26 從外部 API 拉回一批資料要寫進資料庫,重複執行也不該產生重複紀錄)的情境特別實用,比自己寫「先查一次判斷存在與否,再決定要 update 還是 create」的邏輯精簡得多,也少了一次查詢的往返開銷。
// 先取出再改屬性、儲存
$post = Post::find(1);
$post->title = '更新後的標題';
$post->save();
// update():一次更新多個欄位
$post->update(['title' => '更新後的標題']);
// 批量更新(不會觸發個別 Model 的事件與 Observer,效能較好但要留意這個差異)
Post::where('user_id', $userId)->update(['published_at' => now()]);
// increment / decrement:原子操作,適合計數器情境
$post->increment('views');
$post->decrement('views', 5);
有時候你想以既有的一筆資料為範本,快速建立一筆相似的新資料(例如「複製這篇文章當草稿」這種功能),不需要手動把每個欄位一個一個複製出來:
$duplicate = $post->replicate();
$duplicate->title = $post->title.'(複製)';
$duplicate->slug = Str::slug($duplicate->title).'-'.Str::random(4);
$duplicate->published_at = null; // 複製出來的預設是草稿
$duplicate->save();
replicate() 會複製除了主鍵(id)以外的所有欄位屬性,回傳一個「還沒儲存」的新實例,讓你可以在儲存前調整需要不一樣的欄位(像上面範例的 slug 一定要跟原本不同,因為有唯一索引)。
$post = Post::find(1);
$post->delete();
Post::destroy(1); // 依主鍵刪除,可傳陣列一次刪多筆
Post::destroy([1, 2, 3]);
Post::where('published_at', null)->where('created_at', '<', now()->subMonths(6))->delete();
如果需要「軟刪除」(刪除時只是標記,不真的移除資料,方便之後復原),在 Model 加上 SoftDeletes trait:
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
搭配 Migration 加一個 deleted_at 欄位($table->softDeletes();),之後 delete() 就會變成標記時間而不是真的刪除,查詢時軟刪除的資料預設會被自動排除,要看含已刪除資料用 Post::withTrashed()->get()。
軟刪除搭配的完整方法組合:
$post->delete(); // 標記軟刪除(設定 deleted_at)
$post->restore(); // 復原軟刪除的資料
$post->forceDelete(); // 真正從資料庫移除,不可復原
Post::withTrashed()->get(); // 查詢包含已軟刪除的資料
Post::onlyTrashed()->get(); // 只查詢已軟刪除的資料
Post::withTrashed()->find(1)?->restore(); // 常見組合:找出(含已刪除)某筆資料再復原
blog-app 目前的示範沒有特別強調需要軟刪除(文章刪除後直接消失是合理的預設行為),但如果你的專案有「使用者誤刪需要能復原」「刪除紀錄本身要保留稽核軌跡」這類需求,這是最省事的實作方式,比自己額外設計一個 is_deleted 欄位跟對應的查詢邏輯要成熟可靠得多。
當一個業務邏輯需要連續寫入多張表(例如「建立文章」同時要「扣除使用者的每日發文額度」),任何一步失敗都不該留下部分寫入的髒資料,這時候要用資料庫交易:
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($userId, $data) {
$post = Post::create($data);
User::where('id', $userId)->decrement('daily_post_quota');
// 這個閉包內任何一步拋出例外,整個交易都會自動 rollback
});
需要更細緻控制的情境(例如中間要依條件決定要不要 rollback),可以手動控制:
DB::beginTransaction();
try {
$post = Post::create($data);
User::where('id', $userId)->decrement('daily_post_quota');
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
多數情境優先用 DB::transaction() 閉包寫法,程式碼更精簡,也不會漏寫 rollBack()。
DB::transaction() 閉包寫法還接受第二個參數,指定遇到「死結(deadlock)」這類暫時性衝突時要自動重試幾次:
DB::transaction(function () use ($userId, $data) {
$post = Post::create($data);
User::where('id', $userId)->decrement('daily_post_quota');
}, attempts: 3);
死結是資料庫在高併發寫入情境下可能出現的現象——兩個交易互相等待對方釋放鎖,資料庫會偵測到並主動讓其中一個交易失敗。這種失敗通常是暫時性的(換個時間點重跑大機率會成功),attempts 參數讓 Laravel 自動處理這種重試,不需要你自己包一層重試邏輯(跟 Day 8 提過的 retry() 函式是類似的思路,只是這裡是框架內建在交易機制裡的專門處理)。
屬性轉型是很多人(包括過去的我)長期低估的功能:讓資料庫裡存的原始值,讀取到 PHP 時自動轉換成你要的型別。用習慣之後,你不會再看到程式碼裡到處散落 Carbon::parse()、json_decode()、(bool) 這種手動轉換。
Laravel 13 官方文件現在的標準寫法是用 casts() 方法(Laravel 11 新增),取代舊版的 $casts 屬性陣列:
// app/Models/Post.php
class Post extends Model
{
protected function casts(): array
{
return [
'published_at' => 'datetime',
'is_featured' => 'boolean',
'metadata' => 'array',
];
}
}
設定好之後:
$post = Post::find(1);
$post->published_at; // 自動是 Carbon 實例,可以直接呼叫 ->format('Y-m-d')
$post->is_featured; // 自動是 true/false,不是資料庫裡存的 0/1
$post->metadata; // 自動是 PHP 陣列,不用自己 json_decode
(published_at 跟 is_featured 都是 Day 9 建好的實際欄位——加上轉型後,前者可以直接當 Carbon 用、後者拿到的是真正的 true/false 而不是資料庫存的 1/0,之後的範例都會這樣假設;metadata 則還不在 posts 資料表裡,純粹用來示範 array 轉型的語法。)
這裡有個容易混淆的地方要說清楚:市面上很多既有專案(包括你可能維護的舊專案)還在用舊式的 protected $casts = [...] 屬性陣列寫法,這個寫法目前完全沒有被移除、仍然可以正常使用。casts() 方法是官方文件目前呈現的主要寫法,優點是能更方便地帶參數(例如下面的集合轉型範例),如果你是全新專案,建議直接用 casts();如果是維護舊專案,不需要為了跟上文件而特地把 $casts 陣列改寫成方法,兩者可以並存於不同專案,甚至理論上同個 Model 也不衝突(但實務上挑一種風格統一比較好維護)。
需要帶參數的轉型(例如把 metadata 轉成一個自訂的 Collection 類別),casts() 方法寫法會更直觀:
protected function casts(): array
{
return [
'metadata' => AsCollection::using(PostMetadataCollection::class),
];
}
除了範例用到的 datetime/boolean/array,Laravel 內建的轉型型別還有這些常用選項:
| 轉型 | 用途 |
|---|---|
integer / float / string |
基礎型別強制轉換 |
boolean |
轉成 true/false |
array / json |
JSON 字串轉成 PHP 陣列 |
object |
JSON 字串轉成 stdClass 物件 |
collection |
JSON 字串轉成 Laravel Collection |
datetime / immutable_datetime |
轉成 Carbon/CarbonImmutable 實例 |
decimal:2 |
轉成固定小數位數的字串,適合金額欄位(避免浮點數精度問題) |
encrypted |
存進資料庫前自動加密、讀出時自動解密,適合信用卡末四碼這類敏感欄位 |
AsEnumCollection / Enum 類別 |
轉型成 PHP 8.1+ 的原生 Enum 類型 |
blog-app 如果之後想把文章狀態從單純的 published_at 是否為 null,擴充成更明確的「草稿/待審核/已發布/已下架」多種狀態,PHP 8.1+ 的原生 Enum 搭配 Eloquent 轉型是目前最推薦的做法:
// app/Enums/PostStatus.php
enum PostStatus: string
{
case Draft = 'draft';
case PendingReview = 'pending_review';
case Published = 'published';
case Archived = 'archived';
}
// app/Models/Post.php
protected function casts(): array
{
return [
'status' => PostStatus::class,
];
}
$post->status = PostStatus::Published;
$post->status === PostStatus::Published; // true,比較的是型別安全的 Enum 值,不是容易打錯字的裸字串
if ($post->status === PostStatus::Draft) {
// ...
}
跟直接存一個字串欄位('published')相比,Enum 轉型的價值在於編譯期/IDE 就能抓到打錯字的錯誤——如果你手滑打成 'publised'(拼錯),裸字串比對永遠不會相等但也不會有任何警告,直到執行期才會發現邏輯不對;用 Enum 的話,IDE 的自動完成會列出所有合法選項,打錯字直接無法通過型別檢查。這也是 Day 22 會談到的「型別系統能幫你在寫程式的當下就抓到錯誤」精神的一個具體案例。
如果你的 Post 想用 UUID 而不是自增整數當主鍵(常見於需要跨系統合併資料、或不想讓外部看出資料筆數的情境),Laravel 提供 HasUuids trait:
use Illuminate\Database\Eloquent\Concerns\HasUuids;
class Post extends Model
{
use HasUuids;
}
這裡有一個版本差異需要注意:Laravel 12 起,HasUuids trait 產生的 UUID 預設改成 UUIDv7(一種「可依時間排序」的 UUID 格式,對資料庫索引效能比隨機排列的 UUIDv4 更友善),先前版本產生的是 UUIDv4。如果你的專案需要維持舊版的 UUIDv4 行為(例如既有資料已經是 UUIDv4 格式,要保持一致),改用 HasVersion4Uuids trait:
use Illuminate\Database\Eloquent\Concerns\HasVersion4Uuids as HasUuids;
UUIDv4(傳統版本)整組 128 位元幾乎完全隨機,這帶來一個資料庫索引層面的實務問題:當你用 UUID 當主鍵並建立 B-tree 索引,隨機值代表每次新增資料都可能插入到索引樹的任意位置,導致索引頁面頻繁分裂、重新平衡,寫入效能隨資料量增加而下降。UUIDv7 的設計把「時間戳記」編碼進 UUID 的前面幾個位元組,讓新產生的 UUID 在數值上「大致遞增」——效果上接近自增整數主鍵的索引寫入模式(新資料總是插在索引樹的尾端),但依然保有 UUID「全域唯一、不會被外部推測出資料筆數」的特性。這也是為什麼 Laravel 12 起把它設為新的預設值:多數想用 UUID 主鍵的專案,實際上更需要的是 UUIDv7 這種折衷方案,而不是原本 UUIDv4 的純隨機分佈。

blog-app 目前用的是自增整數主鍵,這系列不特別切換成 UUID,這裡是讓你知道「如果之後專案需要 UUID 主鍵,該用哪個 trait、要注意版本差異」。
$fillable 就用 create():會直接拋出 MassAssignmentException,這是 Laravel 刻意設計的保護機制,不是 bug,記得把允許批量賦值的欄位加進 $fillable。update() 卻期待 Observer 被觸發:Post::where(...)->update([...]) 是直接對資料庫下 SQL UPDATE,不會經過個別 Model 實例,因此不會觸發 updating/updated 事件或 Observer,如果你的業務邏輯依賴這些事件,要改成迴圈逐筆 save(),或接受這個效能與行為的取捨。insert()/upsert() 也是同樣的道理,完全不會觸發 Model 事件。casts() 方法命名跟自訂關聯方法衝突:如果你的 Model 剛好也定義了一個叫 casts 的關聯方法(機率很低但不是不可能),會跟基底 Model 類別新增的 casts() 方法衝突,命名時避開這個保留字即可。replicate() 複製出來的資料忘記處理唯一索引欄位:slug 這類有唯一約束的欄位,複製出來的新實例會跟原本的值完全相同,儲存時會因為違反唯一索引而失敗,記得複製後手動調整這類欄位再儲存。DB::transaction() 的 attempts 重試次數設太高,掩蓋了真正的邏輯錯誤:死結重試機制是為了處理「暫時性的併發衝突」,如果你的交易失敗其實是邏輯本身有問題(例如寫入了違反約束的資料),重試再多次也不會成功,只會延長使用者等待失敗結果的時間,attempts 通常 2-3 次就足夠應付真正的暫時性衝突。今天把 CRUD 的寫入操作、Transaction、Attribute Casting 一次補完,也額外深入了大量寫入的 insert()/upsert()、軟刪除的完整方法組合、Enum 轉型這幾個實務上很有價值的進階主題。轉型的部分用的是 Laravel 11 的 casts() 方法寫法($casts 陣列仍完全可用)。UUIDv7 是 Laravel 12 起的預設行為調整,需要維持舊版行為記得換 trait。
業精於勤,荒於嬉 — 韓愈《進學解》
Day 12 進入 View 的世界:Blade 指令全覽、元件、表單處理,這塊在 Laravel 10 到 13 間變動很少,可以喘口氣。