• Час читання ~6 хв
  • 06.06.2022

Одне з найпопулярніших запитань Laravel, яке я чую, це "Як структурувати проект". Якщо ми звузимо його, найбільша його частина звучить як "Якщо логіка не повинна бути в контролерах, то куди її помістити?"

Проблема в тому, що на такі запитання немає єдиної правильної відповіді. Laravel дає вам можливість самостійно вибирати структуру, що є і благословенням, і прокляттям.В офіційних документах Laravel ви не знайдете жодних рекомендацій, тому давайте спробуємо обговорити різні варіанти на основі одного конкретного прикладу.

Примітка: оскільки немає єдиного способу структурувати проект, ця стаття буде сповнена приміток, «що буде, якщо» та подібних параграфів.Я раджу вам не пропускати їх, а прочитати статтю повністю, щоб знати про всі винятки з найкращих методів роботи.

Уявіть, що у вас є метод Controller для реєстрації користувачів, який виконує багато речей:

public function store(Request $request)
{
    // 1. Validation
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);
 
    // 2. Create user
    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);
 
    // 3. Upload the avatar file and update the user
    if ($request->hasFile('avatar')) {
        $avatar = $request->file('avatar')->store('avatars');
        $user->update(['avatar' => $avatar]);
    }
 
    // 4. Login
    Auth::login($user);
 
    // 5. Generate a personal voucher
    $voucher = Voucher::create([
        'code' => Str::random(8),
        'discount_percent' => 10,
        'user_id' => $user->id
    ]);
 
    // 6. Send that voucher with a welcome email
    $user->notify(new NewUserWelcomeNotification($voucher->code));
 
    // 7. Notify administrators about the new user
    foreach (config('app.admin_emails') as $adminEmail) {
        Notification::route('mail', $adminEmail)
            ->notify(new NewUserAdminNotification($user));
    }
 
    return redirect()->route('dashboard');
}

Сім речей, якщо бути точним. Ви всі, напевно, погодитеся, що це занадто багато для одного методу контролера, нам потрібно відокремити логіку і перемістити частини кудись. Але де саме?

  • Services?
  • Jobs?
  • Events/listeners?
  • Action classes?
  • Something else?

Найскладніше в тому, що всі перераховані вище відповіді будуть правильними. Це, мабуть, головне повідомлення, яке ви повинні взяти додому з цієї статті. Я підкреслю це для вас, жирним шрифтом і великими літерами.

ВИ ВІЛЬНІ СТРУКТУРУВАТИ СВІЙ ПРОЕКТ, ЯК ХОЧЕТЕ.

От, я сказав це.Іншими словами, якщо ви десь бачите рекомендовану структуру, це не означає, що вам потрібно стрибати й застосовувати її скрізь. Вибір завжди за вами. Вам потрібно вибрати структуру, яка буде зручною для вас і вашої майбутньої команди, щоб пізніше підтримувати код.

На цьому я, ймовірно, міг би навіть закінчити статтю прямо зараз. Але вам, мабуть, хочеться трохи «м’яса», правда?Добре, добре, давайте пограємо з кодом вище.


Загальна стратегія рефакторингу

По-перше, "відмова від відповідальності", щоб було зрозуміло, що ми тут робимо і чому. Наша загальна мета — зробити метод Controller коротшим, щоб він не містив жодної логіки.

Методи контролера мають виконувати три речі:

  • Accept the parameters from routes or other inputs
  • Call some logic classes/methods, passing those parameters
  • Return the result: view, redirect, JSON return, etc.

Отже, контролери викликають методи, а не реалізують логіку всередині самого контролера.

Також майте на увазі, що запропоновані мною зміни – це лише ОДИН спосіб зробити це, є десятки інших способів, які також підійдуть. Я просто надам вам свої пропозиції з особистого досвіду.


1.Перевірка: класи запиту форми

Це особисті переваги, але я люблю зберігати правила перевірки окремо, і Laravel має чудове рішення для цього: Запити на форму

Отже, ми створюємо:

php artisan make:request StoreUserRequest

Ми переміщаємо наші правила перевірки з контролера до цього класу.

use Illuminate\Validation\Rules\Password;
 
class StoreUserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }
 
    public function rules()
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ];
    }
}

2. Створити користувача: клас обслуговування

use App\Http\Requests\StoreUserRequest;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        // No $request->validate needed here
 
        // Create user
        $user = User::create([...]) // ...
    }
}

Далі нам потрібно створити користувача та завантажити для нього аватар:


Якщо ми дотримуємося рекомендацій, ця логіка не має бути в контролері. Контролери не повинні знати нічого про структуру БД користувача або де зберігати аватари. Потрібно просто викликати якийсь метод класу, який подбає про все.

Досить поширеним місцем застосування такої логіки є створення окремого класу PHP навколо операцій однієї моделі. Він називається сервісним класом, але це просто «вишукана» офіційна назва класу PHP, який «надає послугу» для контролера.

// Create user
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    'password' => Hash::make($request->password),
]);
 
// Avatar upload and update user
if ($request->hasFile('avatar')) {
    $avatar = $request->file('avatar')->store('avatars');
    $user->update(['avatar' => $avatar]);
}

Ось чому немає такої команди, як php artisan make:service, тому що це просто клас PHP з будь-якою структурою, яку ви бажаєте, тому ви можете створити її вручну у своїй IDE, у будь-якій папці.

Як правило, Служби створюються, коли існує кілька методів навколо однієї сутності чи моделі.Отже, створюючи тут UserService, ми припускаємо, що в майбутньому тут буде більше методів, а не лише для створення користувача.

Крім того, служби зазвичай мають методи, які повертають щось (тому "надає послугу"). Для порівняння, дії або завдання зазвичай викликаються без очікування.

У моєму випадку я створю app/Services/UserService.php з одним методом, поки що.

Тоді в контролері ми можемо просто ввести цей клас служби як параметр методу та викликати метод всередині.

Так, нам не потрібно нікуди викликати new UserService(). Laravel дозволяє вводити будь-який клас, подібний до цього, у контролерах, ви можете прочитати більше про введення методів тут у документах.

namespace App\Services;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class UserService
{
    public function createUser(Request $request): User
    {
        // Create user
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);
 
        // Avatar upload and update user
        if ($request->hasFile('avatar')) {
            $avatar = $request->file('avatar')->store('avatars');
            $user->update(['avatar' => $avatar]);
        }
 
        return $user;
    }
}

2.1. Клас обслуговування з принципом єдиної відповідальності

use App\Services\UserService;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
        $user = $userService->createUser($request);
 
        // Login and other operations...

Тепер контролер набагато коротший, але це просте розділення коду копіювання та вставлення є дещо проблематичним.

Перша проблема полягає в тому, що метод Service повинен діяти як "чорний ящик", який просто приймає параметри і не знає, звідки вони беруться. Тому в майбутньому цей метод можна було б викликати з контролера, з команди Artisan або із завдання.

Інша проблема полягає в тому, що метод Service порушує принцип єдиної відповідальності: він створює користувача та завантажує файл.

Отже, нам потрібні ще два "шари": один для завантаження файлів, а другий для перетворення з $request на параметри функції. І, як завжди, є різні способи її реалізації.

Можна подумати, що методи служби занадто малі, щоб їх розділяти, але це дуже спрощений приклад: у реальних проектах метод завантаження файлів може бути набагато складнішим, як і логіка створення користувача.< /p>

У цьому випадку ми трохи відійшли від священного правила «зробити контролер коротшим» і додали другий рядок коду, але, на мою думку, з правильних причин.

3. Може, дія замість послуги?

В останні роки концепція класів Action стала популярною в спільноті Laravel. Логіка така: у вас є окремий клас лише для ОДНЕЇ дії. У нашому випадку класами дій можуть бути:

class UserService
{
    public function uploadAvatar(Request $request): ?string
    {
        return ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
    }
 
    public function createUser(array $userData): User
    {
        return User::create([
            'name' => $userData['name'],
            'email' => $userData['email'],
            'password' => Hash::make($userData['password']),
            'avatar' => $userData['avatar']
        ]);
    }
}

Отже, як бачите, ті самі кілька операцій навколо користувачів, тільки не в одному класі UserService, а розділені на класи Action.Це може мати сенс з точки зору принципу єдиної відповідальності, але мені подобається групувати методи в класи, замість того, щоб мати багато окремих класів. Знову ж таки, це особисте уподобання.

public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
 
    // ...

Тепер давайте подивимося, як виглядав би наш код у випадку класу Action.

Знову ж таки, немає php artisan make:action, ви просто створюєте клас PHP.Наприклад, я створю app/Actions/CreateNewUser.php:

  1. The method createUser() now doesn't know anything about the Request, and we may call it from any Artisan command or elsewhere
  2. The avatar upload is separated from the user creation operation

Ви можете вибрати назву методу для класу Action, мені подобається handle().

Звісно, ​​це можна зрозуміти, але все одно це не очевидно.І наша мета — зробити код більш зручним для підтримки, тому чим менше «сюрпризів», тим краще. Отже, я не великий шанувальник Observers.


Іншими словами, ми перевантажуємо ВСЮ логіку до класу дії, який потім подбає про все, що стосується завантаження файлів і створення користувачів.Чесно кажучи, я навіть не впевнений, чи це найкращий приклад для ілюстрації класів Action, оскільки особисто я не дуже їх шанувальник і не часто їх використовував. Як ще одне джерело прикладів, ви можете взяти перегляньте код Laravel Fortify.

Яка перевага такого підходу з подіями та слухачами?Вони використовуються як "гачки" в коді, і будь-хто інший у майбутньому зможе використовувати цей хук. Іншими словами, ви говорите майбутнім розробникам: "Гей, користувач зареєстрований, подія сталася, і тепер, якщо ви хочете додати тут якусь іншу операцію, просто створіть для неї свій прослуховувач".

  • CreateNewUser
  • UpdateUserPassword
  • UpdateUserProfile
  • etc.

Але, на мою особисту думку, це трохи небезпечна модель.Не тільки логіка реалізації прихована від контролера, але й не зрозуміле саме існування цих операцій. Уявіть, що новий розробник приєднається до команди через рік, чи перевірять вони всі можливі методи спостереження під час підтримки реєстрації користувача?

Коротший, розділений на різні файли і все ще читабельний, чи не так?Знову ще раз повторю, що це лише один із способів виконання цієї місії, ви можете вирішити структурувати її іншим способом.

Яка перевага такого підходу з подіями та слухачами? Вони використовуються як "гачки" в коді, і будь-хто інший у майбутньому зможе використовувати цей хук.Іншими словами, ви говорите майбутнім розробникам: «Гей, користувач зареєстрований, подія відбулася, і тепер, якщо ви хочете додати тут якусь іншу операцію, просто створіть для неї свій слухач».

namespace App\Actions;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class CreateNewUser
{
    public function handle(Request $request)
    {
        $avatar = ($request->hasFile('avatar'))
            ? $request->file('avatar')->store('avatars')
            : NULL;
 
        return User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'avatar' => $avatar
        ]);
    }
}

Але, на мою особисту думку, це трохи небезпечна модель.Не тільки логіка реалізації прихована від контролера, але й не зрозуміле саме існування цих операцій. Уявіть, що новий розробник приєднається до команди через рік, чи перевірять вони всі можливі методи спостереження під час підтримки реєстрації користувача?

Звісно, ​​це можна зрозуміти, але все одно це не очевидно.І наша мета — зробити код більш зручним для підтримки, тому чим менше «сюрпризів», тим краще. Отже, я не великий шанувальник Observers.

public function store(StoreUserRequest $request, CreateNewUser $createNewUser)
{
    $user = $createNewUser->handle($request);
 
    // ...

Яка перевага такого підходу з подіями та слухачами? Вони використовуються як "гачки" в коді, і будь-хто інший у майбутньому зможе використовувати цей хук.Іншими словами, ви говорите майбутнім розробникам: «Гей, користувач зареєстрований, подія відбулася, і тепер, якщо ви хочете додати тут якусь іншу операцію, просто створіть для неї свій слухач».


4. Voucher Creation: Same or Different Service?
Auth::login($user);
 
$voucher = Voucher::create([
    'code' => Str::random(8),
    'discount_percent' => 10,
    'user_id' => $user->id
]);
 
$user->notify(new NewUserWelcomeNotification($voucher->code));

Але, на мою особисту думку, це трохи небезпечна модель.

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

use App\Models\Voucher;
use Illuminate\Support\Str;
 
class UserService
{
    // public function uploadAvatar() ...
    // public function createUser() ...
 
    public function createVoucherForUser(int $userId): string
    {
        $voucher = Voucher::create([
            'code' => Str::random(8),
            'discount_percent' => 10,
            'user_id' => $userId
        ]);
 
        return $voucher->code;
    }
}
public function store(StoreUserRequest $request, UserService $userService)
{
	// ...
 
    Auth::login($user);
 
    $voucherCode = $userService->createVoucherForUser($user->id);
    $user->notify(new NewUserWelcomeNotification($voucherCode));
class UserService
{
    public function sendWelcomeEmail(User $user)
    {
        $voucherCode = $this->createVoucherForUser($user->id);
        $user->notify(new NewUserWelcomeNotification($voucherCode));
    }
$userService->sendWelcomeEmail($user);

Але, на мою особисту думку, це трохи небезпечна модель.

foreach (config('app.admin_emails') as $adminEmail) {
    Notification::route('mail', $adminEmail)
        ->notify(new NewUserAdminNotification($user));
}

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:job NewUserNotifyAdminsJob
class NewUserNotifyAdminsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    private User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
 
    public function handle()
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($this->user));
        }
    }
}
use App\Jobs\NewUserNotifyAdminsJob;
 
class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
    	// ...
 
        NewUserNotifyAdminsJob::dispatch($user);
public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
    $userService->sendWelcomeEmail($user);
    NewUserNotifyAdminsJob::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}

Але, на мою особисту думку, це трохи небезпечна модель.

  1. We're actively creating the user and logging them in
  2. And then something with that user may (or may not) happen in the background. So we're passively waiting for those other operations: sending a welcome email and notifying the admins.

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:event NewUserRegistered
php artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegistered
php artisan make:listener NewUserNotifyAdminsListener --event=NewUserRegistered
use App\Models\User;
 
class NewUserRegistered
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
 
    public User $user;
 
    public function __construct(User $user)
    {
        $this->user = $user;
    }
}
public function store(StoreUserRequest $request, UserService $userService)
{
    $avatar = $userService->uploadAvatar($request);
    $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
    Auth::login($user);
 
    NewUserRegistered::dispatch($user);
 
    return redirect(RouteServiceProvider::HOME);
}
use App\Events\NewUserRegistered;
use App\Services\UserService;
 
class NewUserWelcomeEmailListener
{
    public function handle(NewUserRegistered $event, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
    }
}
use App\Events\NewUserRegistered;
use App\Notifications\NewUserAdminNotification;
use Illuminate\Support\Facades\Notification;
 
class NewUserNotifyAdminsListener
{
    public function handle(NewUserRegistered $event)
    {
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.

php artisan make:observer UserObserver --model=User
use App\Models\User;
use App\Notifications\NewUserAdminNotification;
use App\Services\UserService;
use Illuminate\Support\Facades\Notification;
 
class UserObserver
{
    public function created(User $user, UserService $userService)
    {
        $userService->sendWelcomeEmail($event->user);
 
        foreach (config('app.admin_emails') as $adminEmail) {
            Notification::route('mail', $adminEmail)
                ->notify(new NewUserAdminNotification($event->user));
        }
    }
}

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297