

การเขียน Action Pattern - เพื่อเพิ่มคุณภาพของโค้ดใน Laravel
การแยกไฟล์ Class ด้วย Action Pattern โดยให้ 1 Class มี 1 หน้าที่ด้วย Laravel เพื่อเพิ่มคุณภาพของโค้ด
❓ Action Pattern คืออะไร?#
ถ้าพูดถึง Code Pattern หรือ Design Pattern ในการเขียน Laravel แล้ว หลาย ๆ คนคงจะคุ้นเคยกับ Service Pattern หรือพวก Facade มากกว่า แต่เชื่อว่าหลายคนคงจะไม่คุ้นกับคำว่า Action Pattern สักเท่าไหร่ ซึ่งก็ไม่ใช่เรื่องแปลกอะไรเพราะใน document หลักของ Laravel แทบจะไม่พูดถึงคำนี้เลย แต่จริง ๆ แล้ว Action Pattern นั้นวนเวียนอยู่รอบตัวเราอยู่แล้ว เพียงแต่เราอาจจะแค่ยังไม่เคยทำความรู้จักกับแนวคิดนี้จริง ๆ เท่านั้นเอง
Action Pattern คือ Design Pattern แบบหนึ่ง ที่มีแนวคิดให้ 1 Class มีหน้าที่เพียง 1 อย่างเท่านั้น (Single Responsibility) เพื่อลดความซับซ้อนของโค้ดให้สามารถบริหารจัดการได้ง่ายขึ้น โดย Class ที่เขียนให้รองรับ Action Pattern นั้นมักจะมีเพียง 1 method เท่านั้น เช่น handle() หรือ execute() เป็นต้น
✨ ประโยชน์ของ Action Pattern#
แม้ว่าโดย concept ของตัว Action Pattern นั้นจะบอกเพียงแค่ว่าให้ 1 Class มีหน้าที่เพียง 1 อย่างเท่านั้น แต่ในความเป็นจริงแล้ว เรามักจะใช้ประโยชน์จาก Action Pattern นี้เพื่อแยก business logic ออกมาจาก Laravel Lifecycle ปกติเสียมากกว่า เพื่อให้โค้ดในส่วนที่เป็น business logic นั้นสามารถปรับปรุงแก้ไขและดูแลรักษาได้โดยง่ายนั่นเอง
ซึ่งประโยชน์ของการแยก business logic ออกมานั้นสามารถสรุปออกมาได้เป็นหัวข้อคร่าว ๆ ดังนี้
Single Responsibility#
การเขียนโค้ดโดยยึดหลัก Single Responsibility Principle (SRP) ทำให้เราสามารถจัดระเบียบโค้ดได้ดีขึ้น ลดการผูกกันของโค้ดที่ไม่เกี่ยวข้องกัน เพื่อให้ developer สามารถแก้ไขหรือเพิ่มเติมได้ง่าย โดยที่เราจะสามารถมั่นใจได้ว่าสิ่งที่แก้ไขไปจะไม่ไปกระทบกับ business ส่วนอื่นที่ไม่เกี่ยวข้องกัน
Reusable#
ตัว Class ที่ถูกเขียนแบบ Action Pattern สามารถนำไปใช้ได้อย่างหลากหลาย ไม่ว่าจะเป็นใน Controller เองหรือจะใช้ใน Command, Queue หรือ Event ก็สามารถนำไปต่อยอดได้อย่างง่ายดาย
Test Friendly#
การแยก business logic ออกมาจาก Laravel Lifecycle ปกติทำให้เราสามารถเขียน test ได้แม่นยำขึ้น โดยสามารถเขียน test แยกการทำงานที่อาจเกิดขึ้นใน request เดียว ออกเป็นการ test งานย่อยแต่ละอย่างแทน ทำให้สามารถวิเคราะห์ปัญหาและแก้ไขได้อย่างตรงจุดและรวดเร็ว
Dev Friendly#
การเขียนโดยใช้ Action Pattern นอกจากจะดีในแง่ของระบบแล้วยังดีต่อตัวของ developer เองอีกด้วย เพราะไม่ว่าจะเป็น การ refactor code, การทำ code review หรือแม้แต่การ merge code ภายในทีม ตัว Action Pattern เองก็มีส่วนช่วยให้กิจกรรมเหล่านี้สามารถทำได้ง่าย ทำให้การบริหารจัดการกิจกรรมภายในทีมสามารถทำได้ง่ายยิ่งขึ้น
🚀 ลงมือทำจริง ลดความอ้วนให้กับ Controller แบบดั้งเดิม#
หลังจากที่อ่านแนวคิดและประโยชน์มาเรียบร้อยแล้ว ถึงเวลาลองดูตัวอย่างการใช้งานจริงกัน
สมมุติว่าเรามีไฟล์ Controller ดังนี้
app\Http\Controllers\CheckoutController.php
// app/Http/Controllers/CheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmedMail;
class CheckoutController extends Controller
{
public function store(Request $request)
{
/* 1. Validate */
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
]);
/* 2. Business logic + DB transaction */
/*
*
* Multiple lines of Business logic
*
*/
$order = Order::create($validated);
/* 3. Side-effect : send email */
/*
*
* Multiple lines of Side-effect
*
*/
Mail::to($order->customer->email)->send(new OrderConfirmedMail($order));
/** 4. Response */
return response()->json($order, 201);
}
}phpจะเห็นว่า method store ของ CheckoutController มีจำนวนบรรทัดที่เยอะมากเพราะในการ Checkout และสร้าง Order มักจะมี business logic ที่ซับซ้อน
หากเราลองใช้แนวคิดของ Action Pattern เพื่อแยก business logic ออกมาจะทำให้ Controller ของเรามีหน้าที่แค่ รับ Request (และ validate data) และ Response กลับเพียงเท่านั้น ซึ่งเราสามารถแยก business logic ออกมาได้โดยการเพิ่มไฟล์ Action Class ดังนี้
app\Actions\CreateOrder.php
// app/Actions/CreateOrder.php
<?php
namespace App\Actions;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class CreateOrder
{
public function handle(array $validated): Order
{
/* 2. Business logic + DB transaction */
return DB::transaction(function () use ($validated) {
// สร้าง order จากเฉพาะคอลัมน์จริงของตาราง orders
$order = Order::create([
'customer_id' => $validated['customer_id'],
]);
// วน loop สร้าง order item แยกผ่าน relationship (hasMany)
foreach ($validated['items'] as $item) {
$order->items()->create([
'product_id' => $item['id'],
'qty' => $item['qty'],
]);
}
return $order;
});
}
}phpapp\Actions\SendOrderConfirmedMail.php
// app/Actions/SendOrderConfirmedMail.php
<?php
namespace App\Actions;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmedMail;
class SendOrderConfirmedMail
{
public function handle($order)
{
/* 3. Side-effect : send email */
/*
*
* Multiple lines of Side-effect
*
*/
Mail::to($order->customer->email)->send(new OrderConfirmedMail($order));
}
}phpและใน Controller จะเหลือเพียงแค่
app\Http\Controllers\CheckoutController.php
// app/Http/Controllers/CheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Actions\CreateOrder;
use App\Actions\SendOrderConfirmedMail;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function store(Request $request)
{
/* 1. Validate */
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
]);
/* 2. Business logic + DB transaction */
$order = app(CreateOrder::class)->handle($validated);
/* 3. Side-effect : send email */
app(SendOrderConfirmedMail::class)->handle($order);
/** 4. Response */
return response()->json($order, 201);
}
}phpสังเกตว่าเราได้ย้ายในส่วนของ business logic ทั้งหมด (การสร้าง Order และการส่งอีเมล) ไปยัง Action แทน และให้ตัว Controller ทำหน้าที่เพียงแค่รับและส่ง Request และ Response เท่านั้น ทำให้การแก้ไข business logic ของการสร้าง Order และส่งอีเมลสามารถนำไป reuse ต่อได้ง่ายและยังสามารถปรับปรุงโค้ดต่อได้ง่ายอีกด้วย 🌟
💉 เรียก Action ผ่าน Method Injection แทน app()#
ในตัวอย่างด้านบนเราเรียก Action ผ่าน app(CreateOrder::class)->handle(...) ซึ่งใช้งานได้ดีอยู่แล้ว
แต่ถ้าอยากให้โค้ดสะอาดขึ้นและ test ได้ง่ายกว่าเดิม Laravel เปิดให้เรา type-hint Class ที่ต้องการไว้ที่ parameter ของ method ได้เลย
จากนั้น Service Container จะ resolve ตัว Action มาให้อัตโนมัติ (เรียกว่า Method Injection)
app\Http\Controllers\CheckoutController.php
// app/Http/Controllers/CheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Actions\CreateOrder;
use App\Actions\SendOrderConfirmedMail;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function store(
Request $request,
CreateOrder $createOrder,
SendOrderConfirmedMail $sendOrderConfirmedMail,
) {
/* 1. Validate */
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
]);
/* 2. Business logic + DB transaction */
$order = $createOrder->handle($validated);
/* 3. Side-effect : send email */
$sendOrderConfirmedMail->handle($order);
/** 4. Response */
return response()->json($order, 201);
}
}phpข้อดีของการเขียนแบบนี้คือตอนเขียน test เราสามารถ swap ตัว Action ออกเป็น mock ได้ง่าย
เพราะ Action ถูกฉีดเข้ามาจากภายนอกแทนที่จะไปเรียก app(...) เองภายใน method ทำให้แยกทดสอบแต่ละชิ้นได้ตรงจุดยิ่งขึ้น 🎯
🧪 ทดสอบ Action ของเรากันหน่อย#
หนึ่งในจุดขายของ Action Pattern คือคำว่า Test Friendly แต่จะ Friendly จริงไหมต้องลองเขียน test ดูถึงจะรู้ เพราะ Action แยก business logic ออกมาเป็น Class เดี่ยว ๆ เราจึงเขียน unit test ยิงเข้าไปที่ Action ตรง ๆ ได้เลย โดยไม่ต้องผ่าน HTTP request
tests\Unit\CreateOrderTest.php
// tests/Unit/CreateOrderTest.php
<?php
namespace Tests\Unit;
use App\Actions\CreateOrder;
use App\Models\Customer;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateOrderTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_an_order_with_items(): void
{
$customer = Customer::factory()->create();
$product = Product::factory()->create();
$validated = [
'customer_id' => $customer->id,
'items' => [
['id' => $product->id, 'qty' => 2],
],
];
// เรียก Action ตรง ๆ ไม่ต้องผ่าน Controller
$order = app(CreateOrder::class)->handle($validated);
// assert ว่า order และ order item ถูกบันทึกจริง
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'customer_id' => $customer->id,
]);
$this->assertDatabaseHas('order_items', [
'order_id' => $order->id,
'product_id' => $product->id,
'qty' => 2,
]);
}
}phpจะเห็นว่าเราทดสอบเฉพาะ “การสร้าง Order” ได้โดยไม่ต้องไปยุ่งกับการส่งอีเมลหรือ logic ส่วนอื่นเลย นี่คือความหมายของคำว่า Test Friendly ที่จับต้องได้จริง
ถ้าใช้แพ็กเกจ lorisleiva/laravel-actions ที่จะแนะนำในหัวข้อถัดไป การเรียกใน test จะสั้นลงไปอีกด้วย static method ::run()
ซึ่งเทียบเท่ากับ CreateOrder::make()->handle(...) (และ ::make() ก็เทียบเท่ากับ app(CreateOrder::class))
// tests/Unit/CreateOrderTest.php
$order = CreateOrder::run($validated);php🤔 Action Pattern VS Service Pattern เลือกใช้อย่างไร#
แนวคิด Service ที่อิงกับ Service Container ของ Laravel นั้นสามารถนำมาใช้ในการบริหารจัดการโค้ดได้อยู่แล้วเช่นกัน แล้วทีนี้เราจะเลือกอย่างไรว่าเมื่อไหร่ควรใช้ Service Pattern และเมื่อไหร่ควรใช้ Action Pattern มาลองพิจารณาจากประเด็นเหล่านี้กัน
แนวคิดในการ Design#
- Action Pattern - จะมอง Class ในแง่ของ Behavioral หรือ พฤติกรรม มากกว่า ทำให้ชื่อ Class มักเป็นกิริยา เช่น SendOrderConfirmedMail หรือ CreateOrder เป็นต้น
- Service Pattern - จะมอง Class ในแง่ของ Structural หรือ โครงสร้าง มากกว่า ทำให้ชื่อ Class มักเป็นคำนามแต่แยกการกระทำด้วย method ข้างใน เช่น Order::create() เป็นต้น
ขนาดของทีม#
- Action Pattern - จะมีจำนวนไฟล์เยอะขึ้น แยกการทำงานออกเป็นไฟล์ที่ชัดเจน ลดการ conflict ของ developer ในการแก้ไข
- Service Pattern - จะมีจำนวนไฟล์น้อยกว่า แต่ใน 1 ไฟล์จะมีความสัมพันธ์กันของ method
คุณสมบัติของ OOP#
- Action Pattern - จะเน้น public entry point เดียว (เช่น handle) ในการเรียกใช้งาน (แต่ภายในยังมี private helper หรือ method เสริมอย่าง asController/asJob ได้)
- Service Pattern - จะสามารถดึงคุณสมบัติของการเขียนโปรแกรมแบบ OOP มาใช้ได้อย่างมีประสิทธิภาพกว่า
เมื่อพิจารณาจากความแตกต่างกันระหว่าง Action Pattern และ Service Pattern จะเห็นว่าทั้งคู่มีส่วนช่วยในการเพิ่มคุณภาพของโค้ดเหมือนกัน แต่จะแตกต่างกันที่รายละเอียดและแนวคิดในการนำไปใช้งานมากกว่า ทั้งนี้เราสามารถเลือกใช้งาน Pattern เหล่านี้ได้ตามความเหมาะสมของสถานการณ์ขึ้นอยู่กับการตกลงกันภายในทีม โดยที่เราไม่จำเป็นต้องยึดรูปแบบใดรูปแบบหนึ่งเท่านั้น 🤩
🔁 Action กับเพื่อนบ้าน: Invokable Controller และ Job/Queue#
ตอนต้นบทความเราพูดถึงว่า Action สามารถนำไป reuse เป็น Job หรือ Event ได้ มาดูกันสั้น ๆ ว่าหน้าตาเป็นอย่างไร และ Action ต่างกับ Single-Action (Invokable) Controller ตรงไหน
เทียบกับ Invokable Controller#
Laravel มี Single-Action Controller อยู่แล้ว ซึ่งคือ Controller ที่มี method __invoke() เพียงตัวเดียว
หน้าตาคล้าย Action มากเพราะ “1 Class ทำงานเดียว” เหมือนกัน แต่ต่างกันตรงขอบเขตหน้าที่
app\Http\Controllers\CheckoutController.php
// app/Http/Controllers/CheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Actions\CreateOrder;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function __invoke(Request $request, CreateOrder $createOrder)
{
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
]);
return response()->json($createOrder->handle($validated), 201);
}
}phpผูก route ได้สั้น ๆ แบบนี้
routes\web.php
// routes/web.php
use App\Http\Controllers\CheckoutController;
Route::post('/checkout', CheckoutController::class);phpจุดต่างคือ Invokable Controller ยังเป็น “Controller” ที่ผูกกับ HTTP layer (รับ Request, คืน Response) ส่วน Action เป็น business logic ล้วน ๆ ที่ไม่รู้จัก HTTP เลย จึงนำไป reuse นอก Controller ได้ ดังตัวอย่างถัดไป ในทางปฏิบัติทั้งคู่มักใช้คู่กัน คือ Invokable Controller รับ Request แล้วโยนงานต่อให้ Action
เทียบกับ Job/Queue#
ถ้า business logic ตัวเดิมเป็นงานหนักหรืออยากให้ทำแบบ async เราก็ห่อ logic นั้นด้วย Job แล้วเรียก Action ข้างในได้เลย ตัว Action ยังคงเป็นแหล่งความจริงแหล่งเดียว (single source of truth) ไม่ต้องเขียน logic ซ้ำ
app\Jobs\ProcessCheckout.php
// app/Jobs/ProcessCheckout.php
<?php
namespace App\Jobs;
use App\Actions\CreateOrder;
use App\Actions\SendOrderConfirmedMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class ProcessCheckout implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public array $validated)
{
}
// Job ทำหน้าที่แค่ orchestrate ส่วน logic จริงอยู่ใน Action
public function handle(CreateOrder $createOrder, SendOrderConfirmedMail $sendOrderConfirmedMail): void
{
$order = $createOrder->handle($this->validated);
$sendOrderConfirmedMail->handle($order);
}
}phpแล้ว dispatch จาก Controller ได้ทันที เช่น ProcessCheckout::dispatch($validated);
จะเห็นว่า Action ตัวเดิมถูกนำกลับมาใช้ได้ทั้งใน Controller (แบบ sync) และใน Job (แบบ async) โดยไม่ต้องแก้ตัว Action เลย
นี่คือความหมายของคำว่า Reusable ที่จับต้องได้ 🧩
⚠️ เมื่อไหร่ไม่ควรใช้ และข้อควรระวัง#
Action Pattern ไม่ใช่ยาวิเศษที่เหมาะกับทุกสถานการณ์ ก่อนจะ refactor ทั้งโปรเจกต์ ลองพิจารณาข้อควรระวังเหล่านี้ก่อน
-
File explosion ในโปรเจกต์เล็ก - การแยกทุก business logic ออกเป็น 1 Class ทำให้จำนวนไฟล์เพิ่มขึ้นอย่างรวดเร็ว ถ้าโปรเจกต์ยังเล็กหรือ logic ยังไม่ซับซ้อน การมี Action เป็นสิบ ๆ ไฟล์ที่แต่ละไฟล์มีโค้ดไม่กี่บรรทัด อาจทำให้หาของยากกว่าเดิมและกลายเป็น over-engineering ไปเปล่า ๆ
-
การ orchestrate หลาย Action - เมื่อ flow หนึ่งต้องเรียกหลาย Action ต่อ ๆ กัน คำถามคือ “ใครเป็นคน orchestrate?” ถ้าให้ Controller ทำ Controller ก็จะอ้วนขึ้นอีกครั้ง ถ้าให้ Action เรียก Action ด้วยกันเองก็เสี่ยงผูกกันแน่น (coupling) จนแกะออกยาก ทางออกที่นิยมคือมี Action หรือ Job ระดับบนสุดสัก 1 ตัวไว้คุม flow โดยเฉพาะ (เหมือน
ProcessCheckoutด้านบน) แล้วให้ Action ย่อย ๆ ไม่รู้จักกัน -
จัดการ transaction และ side-effect ให้ชัด - เมื่อ logic กระจายอยู่หลาย Action ต้องระวังเรื่องขอบเขตของ DB transaction และลำดับของ side-effect (เช่น ส่งอีเมล) ว่าควรอยู่ตรงไหน เพื่อไม่ให้ส่งอีเมลออกไปแล้วแต่ transaction ดัน rollback ทีหลัง
สรุปคือ Action Pattern เหมาะกับโปรเจกต์ที่ business logic เริ่มซับซ้อนและมีหลายคนช่วยกันดูแล แต่ถ้าเป็นงานเล็ก ๆ การเขียนตรง ๆ ใน Controller หรือใช้ Service สักตัวอาจเหมาะกว่า เลือกให้พอดีกับขนาดของปัญหาคือหัวใจ 🎯
🐥 ติดปีกให้ Action Pattern ด้วย lorisleiva/laravel-actions#
สำหรับใครที่เริ่มสนใจแนวคิดของ Action Pattern แล้วอยากนำไปต่อยอด ขอแนะนำ package ตัวหนึ่งที่ช่วยให้ใช้งาน Action Pattern ได้หลากหลายยิ่งขึ้น ดูรายละเอียดเพิ่มเติมได้ที่
https://www.laravelactions.com/ ↗
โดยที่ plugin ตัวนี้จะช่วยให้ Class ที่เราเขียนขึ้นสามารถนำไป reuse ใช้งานได้ง่ายยิ่งขึ้น ไม่ว่าจะเป็น Controller, Job หรือ Listener ก็ตาม
ตัวอย่างโค้ดคร่าว ๆ จากในเว็บไซต์มีดังนี้
app\Actions\PublishANewArticle.php
// app/Actions/PublishANewArticle.php
use Lorisleiva\Actions\Concerns\AsAction;
class PublishANewArticle
{
use AsAction;
public function handle(User $author, string $title, string $body): Article
{
return $author->articles()->create([
'title' => $title,
'body' => $body,
]);
}
public function asController(Request $request): ArticleResource
{
$article = $this->handle(
$request->user(),
$request->get('title'),
$request->get('body'),
);
return new ArticleResource($article);
}
public function asListener(NewProductReleased $event): void
{
$this->handle(
$event->product->manager,
$event->product->name . ' Released!',
$event->product->description,
);
}
}phpจะเห็นว่าหากเราวางแนวคิดของ Action Pattern ไว้ตั้งแต่แรก เราสามารถเขียน 1 Class ที่ทำงานเพียงอย่างเดียวแล้วนำไป reuse ใช้ได้โดยง่าย โดยที่ไม่ต้องมีไฟล์ Controller หรือ Listener มา wrap การทำงานเพิ่มไปอีก ทำให้จำนวนไฟล์และโค้ดที่เราเขียนสะอาดมากยิ่งขึ้น (แต่ไม่แนะนำกับระบบใหญ่ ๆ ที่ควรจะวางโครงสร้างให้ดี ไม่เช่นนั้นอาจจะเจอปัญหาในการ refactor ทีหลังได้)
หากมีโอกาสจะมาแนะนำวิธีการใช้งาน package เพิ่มเติมในโอกาสต่อไป 🛠️
ขอให้สนุกกับการเรียนรู้และการเขียนโค้ด 🥰