• Время чтения ~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. Может быть, действие вместо услуги?

В последние годы концепция классов действий стала популярной в сообществе 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