iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

我推的Laravel S2!系列 第 9

我推的Laravel S2|Day 08:好用的 Helpers 精選:Arr、Str、Number、Path、URL、once()

  • 分享至 

  • xImage
  •  

一句話破題

Laravel 的 Helper 函式官方文件列了上百個,逐條讀完不切實際,也沒有人會全部背下來。今天換個角度:挑出最常用的幾十個整理成速查表,外加 Laravel 11 新增的 once()——知道「有這些工具可以用」比「背熟每個函式簽名」重要得多。

Arr:陣列操作系列

Illuminate\Support\Arr 是處理陣列最常用的靜態方法集合。

use Illuminate\Support\Arr;

// get:安全地取巢狀陣列的值,取不到給預設值,不會噴 undefined index 錯誤
$title = Arr::get($post, 'meta.title', '未命名文章');

// only / except:只取或排除特定 key
$summary = Arr::only($post, ['id', 'title', 'published_at']);
$withoutTimestamps = Arr::except($post, ['created_at', 'updated_at']);

// has / exists:判斷 key 是否存在(has 支援點記法巢狀判斷)
if (Arr::has($post, 'author.name')) { /* ... */ }

// map:對每個元素做轉換,同時保留 key
$titles = Arr::map($posts, fn ($post) => $post['title']);

// sort:依值排序,回傳新陣列
$sorted = Arr::sort($posts, fn ($post) => $post['published_at']);

// first / last:取第一個/最後一個符合條件的元素
$latest = Arr::first($posts, fn ($post) => $post['status'] === 'published');

// flatten:把多維陣列壓平成一維
$flat = Arr::flatten(['posts' => ['title' => 'A'], 'meta' => ['views' => 10]]);

// pluck:從一組陣列/物件裡取出指定欄位組成新陣列,跟 collect()->pluck() 效果相同
$titles = Arr::pluck($posts, 'title');
$titlesById = Arr::pluck($posts, 'title', 'id');   // 第二個參數指定要用什麼當 key

// random:隨機取出一個或多個元素
$randomPost = Arr::random($posts);

還有兩個常被忽略、但處理巢狀資料時很好用的全域函式:

// data_get:跟 Arr::get 類似但支援萬用字元 *
$allTitles = data_get($posts, '*.title');

// data_set:安全地設定巢狀陣列的值,路徑中間層不存在會自動建立
data_set($config, 'seo.meta.description', '這是文章摘要');

Str:字串操作系列

Illuminate\Support\Str 是這系列後面幾天會頻繁用到的字串工具集(例如 Day 9 產生文章網址代稱的 Str::slug()),這裡先把常用方法集中整理:

use Illuminate\Support\Str;

Str::slug('Laravel 13 上手筆記');       // 中文不會原樣保留!詳見下方說明
Str::limit($post->body, 100);          // 截斷字串並加上 "...",Day 12 的 Blade 元件範例會用到
Str::words($post->body, 20);           // 依「字詞」數量截斷(跟 limit 依字元數截斷不同)
Str::title('my first post');           // "My First Post",每個字首字母大寫
Str::camel('post_title');              // "postTitle"
Str::snake('postTitle');               // "post_title"
Str::studly('post_title');             // "PostTitle",常用於動態組出類別名稱
Str::plural('post');                   // "posts"
Str::singular('posts');                // "post"
Str::contains($post->title, 'Laravel'); // 判斷是否包含子字串,PHP 8 起也有原生 str_contains() 可用
Str::startsWith($post->slug, 'draft-'); // 判斷開頭
Str::random(32);                        // 產生隨機字串,適合當作暫時性 Token
Str::mask($email, '*', 3);              // 遮蔽字串中間部分,例如信箱顯示成 "abc****@example.com"
Str::uuid();                            // 產生一組 UUID(v4),Day 11 會講到的 HasUuids trait 底層用的就是類似機制

Str::slug() 遇到中文:一個中文圈開發者一定要知道的細節

Str::slug() 是這份清單裡最容易被誤解的一個,而且誤解的代價不小,值得單獨拉出來講。它的方法簽名長這樣:

Str::slug($title, $separator = '-', $language = 'en', $dictionary = ['@' => 'at']);

關鍵在第三個參數 $language 預設是 'en'。只要這個參數不是 nullslug() 內部就會先把字串丟進 Str::ascii() 做「轉寫成 ASCII」的處理,之後才做小寫化跟連字號替換。這代表中文字不會原樣保留在網址代稱裡

Str::slug('Laravel 13 上手筆記');
// 中文會先被轉寫(transliterate)成拉丁字母的近似拼音,
// 大致會得到 "laravel-13-shang-shou-bi-ji" 這種結果,而不是 "laravel-13-上手筆記"

// 想保留中文字本身,要明確把 $language 傳成 null,跳過 ASCII 轉寫這一步
Str::slug('Laravel 13 上手筆記', '-', null);
// "laravel-13-上手筆記"

為什麼傳 null 就能保留?因為跳過 ascii() 之後,slug() 最後那道清理用的正規表達式保留的是 Unicode 的「字母」(\pL)跟「數字」(\pN),中文字本身就屬於 \pL,自然會被留下來。

實務上該選哪一種,沒有標準答案,取決於你要的網址長相:

  • 要純英數網址/posts/laravel-13-shang-shou-bi-ji):用預設行為即可,但要有心理準備轉寫結果的可讀性通常不高,純中文標題轉出來的拼音對讀者幾乎沒有意義。
  • 要中文網址/posts/laravel-13-上手筆記):傳 null 保留中文,瀏覽器會自動做百分號編碼,實際 URL 會變成一長串 %E4%B8%8A...,複製貼上時觀感較差,但在網址列顯示時是可讀的中文。
  • 兩者都不滿意:很多中文內容網站的做法是根本不從標題自動產生 slug,改成讓作者自己填一個英文代稱,或直接退回用 id 當網址識別。

這件事在 Day 9 會有實際後果——那天 Post 的 Observer 會用 Str::slug($post->title) 自動產生代稱,如果 blog-app 的文章標題是純中文(例如「今天天氣真好」),預設行為轉寫出來的結果可能很短、甚至在極端情況下為空字串,而 slug 欄位有唯一索引,兩篇標題轉寫後撞在一起就會直接寫入失敗。Day 9 會再回頭處理這個防呆。

鏈式呼叫:Str::of() 與 str()

如果你需要對同一個字串連續做好幾個操作,Str::of()(或簡寫 str())提供 Fluent(鏈式)風格的 API,比連續巢狀呼叫 Str::xxx(Str::yyy($value)) 好讀得多:

$excerpt = str($post->body)
    ->stripTags()
    ->limit(150)
    ->finish('...')
    ->toString();

// 等效但不用鏈式寫法,可讀性明顯較差
$excerpt = Str::finish(Str::limit(strip_tags($post->body), 150), '...');

Str::of() 回傳的是一個 Stringable 物件,串接的每個方法都回傳新的 Stringable,最後用 ->toString()(或直接在 Blade 裡輸出,會自動轉字串)取出結果。這個模式跟 Day 10 會提到的 Collection 鏈式操作是同一套設計哲學——把「一連串轉換步驟」寫成一行可以從左到右閱讀的鏈式呼叫,而不是由內而外層層包裹的巢狀函式呼叫。

Number:數字格式化系列

Illuminate\Support\Number 處理數字轉換成人類易讀格式:

use Illuminate\Support\Number;

Number::format(1234567.891, precision: 2);   // "1,234,567.89"
Number::currency(1999, in: 'TWD');            // "NT$1,999.00"
Number::fileSize(1024 * 1024 * 3);            // "3 MB"
Number::forHumans(1500000);                   // "1.5 million"
Number::percentage(45.5);                     // "45.50%"
Number::abbreviate(1200);                     // "1.2K"
Number::clamp(150, min: 0, max: 100);         // 100,超出範圍時夾回邊界值,例如限制分頁參數不能亂傳超出範圍的數字
Number::ordinal(3);                            // "3rd"(英文序數,中文情境較少用到)

blog-app 之後如果要顯示文章瀏覽次數、檔案上傳大小,這幾個函式比自己手刻格式化邏輯省事很多,也自動處理了千分位、單位換算這些細節。Number::clamp() 特別適合處理使用者可控的分頁參數(例如 ?per_page=99999 這種惡意或誤植的輸入),一行把數值限制在合理範圍內,比寫一串 if/min/max 巢狀判斷乾淨。

Path:取得專案內各目錄的絕對路徑

app_path('Models/Post.php');       // .../app/Models/Post.php
base_path('composer.json');         // 專案根目錄
config_path('services.php');        // .../config/services.php
database_path('migrations');        // .../database/migrations
public_path('images/logo.png');     // .../public/images/logo.png
resource_path('views/posts');       // .../resources/views/posts
storage_path('app/public');         // .../storage/app/public

這些函式的價值在於跨環境的路徑一致性——不管你的專案部署在哪台主機、哪個路徑下,這些函式永遠回傳正確的絕對路徑,不需要自己手刻字串拼接。

URL:產生連結

route('posts.show', $post);        // 依命名路由產生 URL(Day 7 建立的 posts.show)
url('/posts');                     // 直接組出絕對網址
asset('images/logo.png');          // 對應 public/ 目錄下的靜態資源網址
secure_asset('images/logo.png');   // 強制 HTTPS 版本
to_route('posts.index');           // 產生一個導向該命名路由的 RedirectResponse

Miscellaneous:日常最高頻使用的雜項函式

這個分類光官方文件就超過 50 個,這裡只挑幾乎每個 Laravel 專案都會用到的:

// dd():dump and die,除錯時印出變數內容並中斷執行
dd($post);

// dump():印出變數內容但不中斷執行,適合想連續看多個變數
dump($post->title);

// config() / app() / request() / auth():分別讀取設定、解析容器綁定、取得目前請求、取得認證使用者
$locale = config('app.locale');
$user = auth()->user();

// abort():直接中斷請求並回傳指定的 HTTP 狀態碼
abort_if(! $post->isPublished(), 404);

// collect():把陣列包裝成 Collection,取得一整套鏈式操作方法
$publishedTitles = collect($posts)->where('status', 'published')->pluck('title');

// 安全地存取可能是 null 的物件屬性:現在首選 PHP 8 原生的 nullsafe 運算子 ?->
// (早期常用的 optional() helper 仍然可用,但單純取屬性沒有理由捨棄語言內建語法)
$authorName = $post->author?->name ?? '匿名';

// tap():對一個值執行副作用操作後,回傳原本的值本身(適合鏈式呼叫中插入 side effect)
$post = tap(Post::create($data))->notifyAuthor();

// now():取得目前時間的 Carbon 實例
$post->published_at = now();

// old():Blade 表單重新填值,配合驗證失敗後保留使用者輸入
<input value="{{ old('title', $post->title) }}">

// blank() / filled():比 empty() 更精準地判斷「值是否有意義」,會正確處理空字串、只有空白的字串、空陣列
blank('');          // true
blank('   ');       // true(純空白字串也算 blank,empty() 不會這樣判斷)
filled($request->input('title'));   // 判斷欄位確實有填內容

// with():把一個值傳進閉包做運算,語意上類似「先算出一個中繼值再接著用」
$result = with(Post::count(), fn ($count) => $count > 0 ? "共 {$count} 篇" : '目前沒有文章');

// rescue():執行一段可能拋出例外的邏輯,失敗時回傳預設值而不是讓例外往外拋
$data = rescue(fn () => json_decode(file_get_contents($path), true), []);

// retry():自動重試一段可能因為暫時性問題失敗的邏輯
$response = retry(3, fn () => Http::get('https://api.example.com/posts'), 100);

rescue()retry() 這兩個函式值得多花一點篇幅——它們處理的是「這段程式碼有機率失敗,但失敗的處理方式不一樣」的兩種情境。rescue() 適合「失敗了給個安全的預設值,程式繼續往下跑就好」(例如讀取一個可能不存在的快取檔);retry() 適合「失敗可能只是暫時性的(網路抖動、外部服務短暫不穩),值得再試幾次」,這在 Day 26 呼叫外部 API、Day 29 串接 LINE/OpenAI 這類依賴網路的操作時特別實用。

once():Laravel 11 新增的輔助函式

Laravel 11 新增的小工具,解決一個常見痛點:確保一段邏輯在單次請求中只執行一次,之後的呼叫直接回傳快取的結果,不需要自己手動維護靜態變數或屬性來做記憶化(memoization)。

function expensiveCalculation(): int
{
    return once(function () {
        // 假設這裡有一段很花時間的運算,例如複雜的統計查詢
        return Post::published()->count();
    });
}

expensiveCalculation(); // 實際執行運算
expensiveCalculation(); // 直接回傳快取結果,不會重新查詢
expensiveCalculation(); // 一樣是快取結果

once() 的快取只在單次請求的生命週期內有效,跟 Cache::remember() 那種跨請求持久化的快取是不同層級的東西——如果你只是想避免同一次請求裡重複計算同一個值(例如同一個 view 裡好幾個地方都要用到「目前使用者的文章總數」),once() 比自己手動加一個靜態變數要乾淨得多。原本 Laravel 生態圈裡很多專案會另外裝 spatie/once 這個第三方套件做同樣的事,Laravel 11 把它內建進框架後,這個第三方套件就不需要了(如果你的 composer.json 還有它,可以移除以避免功能重複)。

once() 也可以在類別方法內使用,這時候的記憶化範圍是「同一個物件實例」,不是全域共用:

class Post extends Model
{
    public function readingTime(): int
    {
        return once(fn () => (int) ceil(str_word_count($this->body) / 200));
    }
}

同一個 $post 物件實例在單次請求裡多次呼叫 $post->readingTime(),只有第一次會真的計算字數,後續呼叫直接回傳快取值——如果這個方法在同一個 view 裡被呼叫好幾次(例如列表頁跟側邊欄都要顯示閱讀時間),這個機制能省下重複計算的成本。

常見錯誤與踩雷點

  • 在正式環境的程式碼裡留下 dd()dd() 會直接中斷整個請求並輸出除錯資訊給前台看到,忘記移除是很常見但很尷尬的疏失,養成 commit 前搜尋一次 dd(/dump( 的習慣。
  • once() 誤用成跨請求快取once() 只在單一請求生命週期內有效,每次新的 HTTP 請求都會重新執行一次,不要拿它取代 Cache::remember()
  • Arr::get() 的點記法路徑打錯Arr::get($data, 'a.b.c') 只要中間任何一層不是陣列(例如是物件),就會直接回傳預設值而不是報錯,除錯時容易誤以為「資料本來就沒有」,其實是路徑格式不對。
  • empty() 判斷字串是否有內容,卻誤判純空白字串empty(' ') 會回傳 false(因為字串長度不是 0),如果你的驗證邏輯用 empty() 判斷使用者是否真的填了內容,純空白的輸入會被誤判成「有填」,這正是 blank()/filled() 存在的理由。
  • retry() 沒有限制重試的錯誤類型,導致明確不該重試的錯誤也被重試:例如驗證失敗、404 這類「重試也不會成功」的錯誤,如果整段邏輯都包進 retry(),會白白浪費時間重試注定失敗的操作,實務上建議搭配例外類型判斷,只對真正「可能是暫時性問題」的錯誤重試。

小結

Helper 函式的價值不在於背熟每一個,而在於知道「遇到這種情境,Laravel 通常已經幫你寫好一個現成的工具」,真正動手寫的時候再回頭查文件確認簽名即可。今天除了 Arr/Number/Path/URL 這些分類,也補上了 Str 的鏈式 API、blank()/filled()/rescue()/retry() 這幾個實務上很常用但容易被忽略的函式,並把 once() 併進來講——這是這系列「新功能就近安插進相關主題」的一個小例子。

他山之石,可以攻錯 — 《詩經・小雅》

明日預告

Day 9 開始進入 Eloquent 的世界,我們會正式建立 blog-appPostUser 模型與資料表,為後面好幾天的內容打地基。


上一篇
我推的Laravel S2|Day 07:路由(Route):HTTP 動詞、Resource 路由、命名/群組、每秒級流量限制
下一篇
我推的Laravel S2|Day 09:Eloquent Model 入門:連線、Migration、關聯
系列文
我推的Laravel S2!10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言