Mấy hôm nay mình gặp lại một kiểu callback khá quen: chỉ muốn cố định một đối số, nhưng vẫn phải bọc cả lời gọi trong arrow function. Bài này giúp các bạn hiểu và tự thử partial function application PHP 8.6, rồi quyết định lúc nào cú pháp mới thực sự dễ đọc hơn. Mình không chỉ tóm tắt RFC: mình đã chạy binary PHP 8.6.0 Alpha 3 chính thức trên Windows, đối chiếu Reflection, lỗi runtime và PHPStan. Giới hạn quan trọng nhất là Alpha 3 chỉ dành cho kiểm thử, chưa được dùng ở production; tooling cũng chưa theo kịp hoàn toàn.

Kết quả ngắn gọn: dấu ? giữ đúng một vị trí và trả về Closure; dấu ... chuyển tiếp phần đối số còn lại. Cú pháp này rất gọn cho callback một dòng và pipe, nhưng closure cũ vẫn sáng sủa hơn khi logic có nhánh, cần debug hoặc dự án còn hỗ trợ PHP cũ.

Vì sao callback PHP lại cần thêm một cú pháp mới?

Ví dụ mình muốn biến một chuỗi thành slug. Hàm có hai đối số, còn callback chỉ nhận chuỗi đầu vào. Với PHP hiện tại, đoạn quen thuộc sẽ như sau:

function slugify(string $value, string $separator = '-'): string
{
    return strtolower(str_replace(' ', $separator, trim($value)));
}

$callback = static fn(string $value): string => slugify($value, '-');
echo $callback('  Hello World  '); // hello-world

Arrow function ở đây không sai. Nó chỉ hơi “làm thủ tục”: nhận $value, chuyển nguyên xi vào slugify(), còn dấu gạch ngang đã được cố định. Partial function application, viết tắt là PFA, cho phép đặt placeholder ngay ở vị trí còn thiếu:

$callback = slugify(?, '-');

var_dump(get_debug_type($callback)); // string(7) "Closure"
echo $callback('  Hello World  ');    // hello-world

Điểm quan trọng là PHP chưa gọi slugify() ở dòng đầu. Engine tạo một closure, giữ sẵn dấu -, rồi chờ giá trị cho vị trí ?. Nó gần partial application hơn currying: mình cố định một phần danh sách đối số trong một lần, chứ không biến mọi tham số thành chuỗi hàm một đối số.

Ba đường viết callback khác nhau cùng đi đến một kết quả
Closure, arrow function và partial function application có thể cho cùng output, nhưng đường đi và mức dễ đọc khác nhau.

Môi trường Alpha 3 mình dùng để thử

Mình tải bản VS18 x64 Non Thread Safe từ trang pre-release chính thức, đặt riêng trong thư mục lab và không thay PHP của Herd hay website. File ZIP có SHA-256 ac3a4254...3071417, khớp manifest trên php.net. Runtime trả về:

PHP 8.6.0alpha3 (cli) (built: Jul 28 2026 13:41:09) (NTS Visual C++ 2026 x64)
Zend Engine v4.6.0-dev
Loaded Configuration File: (none)

Mình chạy ngày 01/08/2026 trên Windows x64, không nạp php.ini. File lab được kiểm tra bằng php -l trước, sau đó chạy các assertion cho function, static method, object method, named argument, optional parameter, variadic và pipe. Tất cả assertion đều pass. Cách tách runtime này cùng tinh thần với việc chạy công cụ PHP trong môi trường cô lập: thử đồ mới thì vui, nhưng đừng để production lãnh đủ.

PHP công bố Alpha 3 ngày 30/07/2026 và dự kiến Beta 1 vào 13/08. Chính trang phát hành ghi rõ đây là bản test sớm, không dùng production. Mình nhắc hơi nhiều vì chữ “implemented” trong UPGRADING rất dễ tạo cảm giác tính năng đã sẵn sàng cho mọi dự án. Đời không như mơ nhanh vậy.

Partial function application PHP 8.6 hoạt động ra sao?

Theo RFC v2, chỉ cần một lời gọi có ? hoặc ..., PHP sẽ tạo closure thay vì thực thi hàm ngay. Closure mới kế thừa tên, kiểu, cách truyền tham chiếu và kiểu trả về của các tham số chưa được điền. Mình kiểm tra bằng Reflection chứ không đoán từ cú pháp:

$callback = slugify(?, '-');
$reflection = new ReflectionFunction($callback);

foreach ($reflection->getParameters() as $parameter) {
    printf(
        "%s | %s | required=%s\n",
        $parameter->getName(),
        $parameter->getType(),
        $parameter->isOptional() ? 'no' : 'yes',
    );
}

// value | string | required=yes

Placeholder ? giữ đúng một vị trí

Mỗi ? tương ứng đúng một đối số. Nó có thể nằm đầu, giữa, cuối hoặc trong named argument. Hai placeholder sẽ tạo closure có hai tham số theo đúng type của hàm gốc:

final class Formatter
{
    public static function wrap(
        string $value,
        string $left,
        string $right,
    ): string {
        return "$left$value$right";
    }
}

$wrap = Formatter::wrap(?, '[', ?);
echo $wrap('PHP', ']'); // [PHP]

Một thay đổi dễ bị các bài cũ mô tả sai nằm ở optional parameter. RFC bổ sung tháng 5/2026 đã chốt rằng mọi vị trí dùng ? đều trở thành required, kể cả tham số gốc có default. Trong lab, describe('guide', ?) tạo tham số prefix kiểu stringrequired=true. Gọi closure mà không truyền gì nhận ArgumentCountError, đúng với Alpha 3.

function describe(
    string $value,
    string $prefix = 'post',
    string $suffix = 'html',
): string {
    return "$prefix:$value.$suffix";
}

$named = describe(value: ?, suffix: 'json');
echo $named('guide'); // post:guide.json

$requiredPrefix = describe('guide', ?);
$requiredPrefix(); // ArgumentCountError

Placeholder ... chuyển tiếp phần đối số còn lại

Dấu ... không có nghĩa “chính xác một đối số”. Nó nhận zero hoặc nhiều đối số còn lại. Đây cũng là chỗ dễ nhầm với first-class callable foo(...): cùng ký hiệu, nhưng khi có thêm giá trị đã điền trước, nó trở thành partial application.

function combine(string $glue, string ...$parts): string
{
    return implode($glue, $parts);
}

$joinWithSlash = combine('/', ...);

echo $joinWithSlash();              // chuỗi rỗng
echo $joinWithSlash('a');           // a
echo $joinWithSlash('a', 'b', 'c'); // a/b/c

Reflection cho closure trên trả một tham số parts, type string, variadic=true và không required. RFC còn cảnh báo ... có thể chuyển tiếp đối số thừa vào optional parameter của hàm gốc. Vì vậy callback dùng trong array_filter() hoặc array_find() cần được nhìn cả signature, không chỉ thấy code ngắn là oke.

Ổ đơn nhận đúng một đối số bên cạnh khay co giãn nhận nhiều đối số
Placeholder đơn giữ đúng một vị trí bắt buộc; khay co giãn minh họa phần đối số còn lại có thể rỗng hoặc có nhiều giá trị.

So với arrow function và first-class callable

Mình cho cùng input chạy qua ba cách viết và assert output giống hệt nhau:

$arrow = static fn(string $value): string => slugify($value, '-');
$partial = slugify(?, '-');
$firstClass = slugify(...);

assert($arrow($input) === 'partial-function-application');
assert($partial($input) === $arrow($input));
assert($firstClass($input) === $arrow($input));
Cách viết Phù hợp nhất Điểm cần nhớ
First-class callableGiữ nguyên toàn bộ signatureKhông cố định đối số nào
Arrow functionBiến đổi ngắn nhưng có biểu thức riêngRõ với team đang dùng PHP cũ
PFACố định vài đối số, giữ type còn lạiCần PHP 8.6 và parser hỗ trợ

PFA không “thay thế closure”. Nó là cách viết một nhóm closure chuyển tiếp đối số. Closure đầy đủ vẫn cần thiết khi có nhiều câu lệnh, bắt biến theo ngữ cảnh, log, xử lý exception hoặc đặt breakpoint ở giữa logic.

Mình thử ba tình huống gần code Laravel

Mình không dựng một project Laravel rồi tuyên bố framework đã hỗ trợ Alpha 3. Thay vào đó, mình viết PHP thuần theo ba mô-típ hay gặp trong app Laravel: chuẩn hóa slug, lọc collection-like array và tạo callback từ service method. Như vậy mình kiểm tra đúng semantics ngôn ngữ mà không đánh tráo thành chứng nhận tương thích framework.

$slugger = slugify(?, '-');
echo $slugger('  Hello World  '); // hello-world

$entries = [
    ['slug' => 'first', 'status' => 'draft'],
    ['slug' => 'second', 'status' => 'published'],
];

$filter = new EntryFilter();
$publishedOnly = $filter->matches(?, 'published');
$published = array_values(array_filter($entries, $publishedOnly));

Kết quả lọc giữ lại second. Object method PFA mang theo object đã bind, còn Reflection vẫn biết tham số đầu là array. Với service có callback một đối số và cấu hình cố định, mình thấy cách này khá sáng. Nhưng nếu callback còn kiểm tra quyền, ghi log và đổi exception, mình vẫn đặt tên closure riêng để lúc trace không phải chơi trò truy tìm kho báu.

PFA đi cùng pipe operator có dễ đọc hơn không?

Pipe operator đã có từ PHP 8.5 và là một lý do PFA được quan tâm. Mình chạy pipeline trim → thay khoảng trắng → lowercase trên Alpha 3:

$result = $input
    |> trim(?)
    |> str_replace(' ', '-', ?)
    |> strtolower(?);

echo $result; // partial-function-application

Với ba bước unary rõ nghĩa, mình thích phiên bản pipe: dữ liệu đi từ trên xuống, không có biến trung gian và cũng không lồng ba cặp ngoặc. Tuy nhiên lợi ích giảm nhanh nếu mỗi bước cần hai placeholder, named argument khó đoán hoặc có side effect. Khi review, mình ưu tiên câu hỏi “đọc từ trái sang phải có biết dữ liệu vào chỗ nào không?” hơn việc đếm ít hơn vài ký tự.

Tooling nào theo kịp Alpha 3?

PHP 8.6 Alpha 3 tự parse và chạy file lab bình thường. Nhưng khi mình tải PHPStan bản phát hành mới nhất tại thời điểm thử, PHPStan 2.2.7, rồi phân tích file PFA tối thiểu ở level 5, kết quả là:

Line 10: Syntax error, unexpected '?'
Identifier: phpstan.parse (non-ignorable)
Result is incomplete because of severe errors.

Đây không phải lỗi code runtime; nó cho thấy parser của tool chưa hiểu cú pháp mới. File composer.json nhánh Laravel 13.x hiện khai báo PHP ^8.3 và có polyfill PHP 8.6, nhưng constraint đó không đồng nghĩa mọi formatter, static analyzer, IDE plugin và package trong một project đã hiểu PFA. Vì vậy mình chỉ gọi ba ví dụ trên là “Laravel-style”, không gọi là kiểm thử tích hợp Laravel.

Chỗ này giống câu chuyện kiểm tra chất lượng code Laravel: runtime cho output đúng mới là lớp đầu. CI, static analysis, convention và khả năng cả team đọc được mới quyết định đoạn code có nên merge hay không.

Khi nào mình dùng, khi nào vẫn chọn closure cũ?

Tình huốngLựa chọn của mìnhLý do
Callback một dòng, cố định một đối sốPFA sau khi lên PHP 8.6 stableNgắn và Reflection giữ type
Pipeline nhiều bước unaryPFA với pipeLuồng dữ liệu dễ nhìn
Logic có nhánh, log hoặc exceptionClosure có tên biến rõDễ debug và review hơn
Package hỗ trợ PHP cũArrow function hoặc closureKhông phá compatibility
CI chưa parse được cú phápChưa merge PFATránh tắt static analysis để chạy cho qua

Với mình, PFA giải quyết đúng một nỗi khó chịu có thật: wrapper callback chỉ làm nhiệm vụ chuyển tiếp. Alpha 3 cho thấy semantics đã chạy được, kể cả optional placeholder mới, named argument, object method và pipe. Nhưng lỗi PHPStan 2.2.7 là lời nhắc rất thực tế rằng thời điểm “ngôn ngữ có tính năng” và thời điểm “dự án nên dùng tính năng” không phải lúc nào cũng trùng nhau.

Nếu các bạn muốn thử, hãy dùng runtime tách biệt, giữ test so sánh với closure cũ và kiểm tra toàn bộ CI trước. Còn production thì chờ stable cùng toolchain đã xanh. Những bài toán nhiều bước như vòng lặp đệ quy trong Laravel vẫn nên ưu tiên code có tên và luồng điều khiển rõ; đừng dùng cú pháp mới chỉ vì nó mới.

Nguồn chính và ngày kiểm tra 01/08/2026: