[課程筆記] Small Reservation Project Step-by-Step

前言

Laravel 10: Small Reservation Project Step-By-Step 是教學網站 Laravel Daily 於 2023 年推出的專欄課程,課程中基於自由職業接案平台 Upwork 上的一個真實需求,逐步地構建一個預約系統。

需求描述

接案平台上的需求,由於客戶本身可能並不具備開發經驗或相關專業知識,所以經常會以他們自己的方式去描述需求,因此並不會將具體的內容描述地十分仔細。

比如這個來自於 Upwork 上的真實案例:

Hi,

I’m looking for help to build a straightforward reservation system in Laravel. I’ve built a similar system using Node.js, Express.js and MongoDB, but want to scale the project and add some functionalities.

Essentially, the system allows for adding clients, activities, and send out a link to fill in their sizes. The system also generates a PDF for each trip on demand for the guide.

I’m short on time so I’d appreciate help 🙂

Code should be clean, commented, DRY, … etc. Well built logic, readable, and project structure. We’ll get a basic version up and running, then add functionalities along the way, such as online payment integration etc… layout should be responsive and optimised for mobile.

Thanks for reading!

釐清需求

為此,作為一名開發者需要將這樣的需求描述轉換為適當的行動計畫(Plan of Action),反覆地蒐集相關資訊並向業主提出問題,逐漸地去完善整個計畫;畢竟釐清整個思路與需求的最佳方式 —— 就是直接詢問客戶

比如對於上述的需求,或許可以詢問以下問題:

  • 需求中沒有提到前端視覺設計,有什麼想法嗎?或者是希望可以使用既有的模板?(可以附上連結)
  • 每一個公司是否只有一位使用者能夠管理活動呢?還是需要有多個使用者具備管理權限?(表示需要額外付費)
  • 所謂的活動,需要具備哪些資訊?標題、敘述、照片、價格等就足夠了嗎?還是需要別的東西?
  • 生成的 PDF 文件會長什麼樣子?有實際的範例可以供參考嗎?

小撇步

我們甚至可以先根據需求創建資料庫的表結構後,交予客戶檢視並詢問是否足夠,或是缺少哪些欄位。讓對方先看到在專案上投入了部分時間,會更有機會獲取到這份工作

行動計畫

在釐清需求的過程中,我們就能夠逐步地去建立行動計畫(Plan of Action),或者更具體地來說 —— 規劃資料庫的表結構與模型條列待實作的功能清單

初始化專案

創建專案並進行資料庫遷移

我們可以透過以下命令初始化一個 Laravel 專案:

# 創建專案
$ laravel new reservations

初始完專案之後,透過 php artisan 命令創建資料庫模型與對應的遷移文件:

$ cd reservations

# 創建模型與遷移文件
$ php artisan make:model Role --migration
$ php artisan make:migration add_role_id_to_users_table

$ php artisan make:model Company --migration
$ php artisan make:migration add_company_id_to_users_table

$ php artisan make:model Activity --migration
$ php artisan make:migration add_activity_user_table

說明

php artisan make:model [SNAME_CASE_TABLE_NAME(SINGULAR)] --migration 命令中的 --migration 參數(也可以簡寫以 -m 表示),會在創建 ORM 模型時也創建對應的遷移文件,即生成 app/Models/[SNAKE_CASE_TABLE_NAME(SINGULAR)].php 文件和 datebase/migrations/xxxx_xx_xx_xxxxxx_create_[SNAKE_CASE_TABLE_NAME(PLURAL)]_table.php 文件。

若沒有添加該參數,則需要自行透過資料庫創建資料表,或者是使用 php artisan make:migration create_[SNAKE_CASE_TABLE_NAME(PURAL)]_table 創建對應的遷移文件。

在創建完對應的 Model 文件和 Migration 文件後,我們需要根據資料表的欄位與對應關係修改文件中的內容。

Role 資料表

database/migrations/xxxx_xx_xx_xxxxxx_create_roles_table.php
public function up(): void
{
    Schema::create('roles', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->timestamps();
    });
}
app/Models/Role.php
class Role extends Model
{
    use HasFactory;
    
    protected $fillable = ['name'];
}
database/migrations/xxxx_xx_xx_xxxxxx_add_role_id_to_users_table.php
schema::table('users', function (Blueprint $table) {
    $table->foreignId('role_id')->constrained();
});
app/Models/User.php
class User Extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password', 'role_id'];
    
    // ...
    
    public function role()
    {
        return $this->belongsTo(Role::class);
    }
}

Company 資料表

database/migrations/xxxx_xx_xx_xxxxxx_create_companies_table.php
public function up(): void
{
    Schema::create('companies', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->timestamps();
    });
}
app/Models/Company.php
class Company extends Model
{
    use HasFactory;
    
    protected $fillable = ['name'];
}
database/migrations/xxxx_xx_xx_xxxxxx_add_company_id_to_users_table.php
schema::table('users', function (Blueprint $table) {
    $table->foreignId('company_id')->nullable()->constrained();
});
app/Models/User.php
class User Extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password', 'role_id', `company_id`];
    
    // ...
    
    public function role()
    {
        return $this->belongsTo(Role::class);
    }
    
    public function company()
    {
        return $this->belongsTo(Company::class);
    }
}

Activity 資料表

database/migrations/xxxx_xx_xx_xxxxxx_create_activities_table.php
public function up(): void
{
    Schema::create('activities', function (Blueprint $table) {
        $table->id();
        $table->foreignId('company_id')->constrained();
        $table->foreignId('guide_id')->nullable()->constrained('users');
        $table->string('name');
        $table->text('description');
        $table->dateTime('start_time');
        $table->integer('price');
        $table->string('photo')->nullable();
        $table->timestamps();
    });
}
app/Models/Activity.php
class Company extends Model
{
    use HasFactory;
    
    protected $fillable = [ 'company_id', 'guide_id', 'name', 'description', 'start_time', 'price', 'photo' ];
    
    public function company()
    {
        return $this->belongsTo(Company::class);
    }
    
    public function participants()
    {
        return $this->belongsToMany(User::class)->withTimestamps();
    }
}
schema::create('activity_user', function (Blueprint $table) {
    $table->foreignId('activity_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->timestamps();
});

修改完上述文件後,我們可以執行以下命令進行資料庫遷移:

$ php artisan migrate

添加依賴實現身分驗證

Laravel 提供了一系列的入門套件(Starter Kits)幫助開發者快速建構應用程式,其中 Laravel Breeze 包含基礎的身分驗證功能,如登入、註冊、重設密碼、電子郵件驗證等。

首先,我們需要透過 composer 來安裝依賴,並執行 php artisan breeze:install 打包前端資源:

$ composer require laravel/breeze --dev
$ php artisan breeze:install blade

建立使用者種子資料

我們在 User 模型中添加了 role_id 欄位,但資料庫中尚未有任何資料,如果此時進行註冊,系統會拋出以下錯誤:

SQLSTATE[HY000]: General error: 1364 Field 'role_id' doesn't have a default value

為此,我們需要創建預設的 role 資料,並在 user 註冊時賦予角色代號;我們經常在開發中需要事先在資料表中填入資料,比如有些功能需要在有資料的狀況下才能正常運作,或者是基於開發需求需要預先使用測試資料

除了直接操作資料庫添加資料之外,在 Laravel 中可以使用 Seeder 來實現這樣的操作,執行以下命令創建 RoleSeeder 文件:

$ php artisan make:seeder RoleSeeder

我們需要在 RoleSeeder.php 文件中撰寫插入資料的操作,在將這個 Seeder 添加到 DatabaseSeeder.php 文件中:

database/seeders/RoleSeeder.php
use App\Models\Role;
 
class RoleSeeder extends Seeder
{
    public function run(): void
    {
        Role::create(['name' => 'administrator']);
        Role::create(['name' => 'company owner']);
        Role::create(['name' => 'customer']);
        Role::create(['name' => 'guide']);
    }
}
database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            RoleSeeder::class,
        ]);
    }
}

在使用者註冊時,需要分派一個 role 給他,我們當然可以在 Laravel Breeze 的 RegisteredUserController 控制器中直接指派對應的角色編號,但這樣的實現並不夠優雅;在此可以善用 PHP 中的 Enum:

app/Enums/Role.php
enum Role: int
{
    case ADMINISTRATOR = 1;
    case COMPANY_OWNER = 2;
    case CUSTOMER = 3;
    case GUIDE = 4;
}
app/Http/Controllers/Auth/RegisteredUserController.php
use App\Enums\Role;
 
class RegisteredUserController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        // ...
 
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'role_id' => Role::CUSTOMER->value, 
        ]);
 
        // ...
    }
}

增刪改查操作:以 Company 資源為例

創建控制器與添加路由

我們可以使用 php artisan make:controller 命令創建控制器(Controller),添加對應的增刪改查邏輯,並將他添加到路由(Route)中:

$ php artisan make:controller CompanyController
app/Http/Controllers/CompanyController.php
use App\Models\Company;
use Illuminate\View\View;
use App\Http\Requests\StoreCompanyRequest;
use App\Http\Requests\UpdateCompanyRequest;
 
class CompanyController extends Controller
{
    public function index(): View
    {
        $companies = Company::all();
 
        return view('companies.index', compact('companies'));
    }
    
    public function create(): View
    {
        return view('companies.create');
    }
    
    public function store(StoreCompanyRequest $request): RedirectResponse
    {
        Company::create($request->validated());
 
        return to_route('companies.index');
    }
 
    public function edit(Company $company)
    {
        return view('companies.edit', compact('company'));
    }
 
    public function update(UpdateCompanyRequest $request, Company $company): RedirectResponse
    {
        $company->update($request->validated());
 
        return to_route('companies.index');
    }

    public function destroy(Company $company)
    {
        $company->delete();

        return to_route('companies.index');
    }
}
routes/web.php
use App\Http\Controllers\CompanyController;
 
Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
 
    // ...
 
    Route::resource('companies', CompanyController::class); 
});

說明

使用 Route::resource() 註冊資源化路由,這一條路由會創建多個路由,用來處理各式各樣與特定資源相關的 RESTful 操作。除此之外,我們也可以使用 php artisan make:controller [CamelCaseController] --resource 命令創建資源控制器(Resource Controller)。

驗證表單內容:使用 Form Requests

創建與編輯操作需要驗證表單內容,可以使用 Laravel 的 Form Requests 來實現,我們可以使用 php artisan make:request 命令創建相關文件:

$ php artisan make:request StoreCompanyRequest
$ php artisan make:request UpdateCompanyRequest
app/Http/Requests/StoreCompanyRequest.php
class StoreCompanyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
 
    public function rules(): array
    {
        return [
            'name' => ['required', 'string'],
        ];
    }
}
app/Http/Requests/UpdateCompanyRequest.php
class UpdateCompanyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
 
    public function rules(): array
    {
        return [
            'name' => ['required', 'string'],
        ];
    }
}

至於 View 層的模板實現就不在此處贅述了,可以在課程的 GitHub 專案倉庫中檢視。

操作權限控制:使用 Middleware

系統中資源的增刪改查操作,通常不會對所有使用者開放,因此需要增加權限控制機制,比如確認使用者為特定的角色;為了實現這樣的功能,我們可以透過 Laravel 中的 Middleware 來處理。

使用 php artisan make:middleware 命令創建相關文件,並在 Kernel.php 文件中添加中間件的別名;中間件的作用時機是在訪問 Company 資源時,還需要在路由中添加中間件:

$ php artisan make:middleware IsAdminMiddleware
app/Http/Kernel.php
class Kernel extends HttpKernel
{
    // ...
 
    protected $middlewareAliases = [
        'auth' => \App\Http\Middleware\Authenticate::class,
 
        // ...
 
        'isAdmin' => \App\Http\Middleware\IsAdminMiddleware::class, 
    ];
}
app/Http/Middleware/IsAdminMiddleware.php
use App\Enums\Role;
use Symfony\Component\HttpFoundation\Response;
 
class IsAdminMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        abort_if($request->user()->role_id !== Role::ADMINISTRATOR->value, Response::HTTP_FORBIDDEN);
 
        return $next($request);
    }
}
routes/web.php
Route::middleware('auth')->group(function () {
    // ...
 
-    Route::resource('companies', CompanyController::class); 
+    Route::resource('companies', CompanyController::class)->middleware('isAdmin'); 
});

前端渲染模板:使用 View

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *