Why is Laravel so excellent

Content

Laravel has always been the most elegant backend framework in my mind. In order to explain to more people why Laravel is so elegant, what operations the framework itself has done, and its advantages compared to other frameworks, I am going to start with the most commonly used CURD example in the backend, step by step explaining how Laravel completes this process, and why I (we) like to use Laravel.

Introduction Laravel #

Laravel's positioning is a full-stack WEB framework, which provides a full set of components for WEB development; such as routing, middleware, MVC, ORM, Testing, etc. In this article, the demo I used is the latest version of Laravel 10.x and PHP 8.2. Although the version of Laravel has changed rapidly since Laravel 5.x, with basically one major version per year, its core has hardly changed significantly since 4.X. The directory structure of Laravel may seem cumbersome to first-time users, with about a dozen folders, but in reality, the positions of most folders are carefully designed and are where they should be.

file

Laravel Artisan #

Laravel's first elegant design is to expose an ALLINONE entry for developers - Artisan. Artisan is a SHELL script and the only entry point for interacting with Laravel through the command line. All interactions with Laravel, including queue operations, database migration, and template file generation, can be completed using this script, which is also one of the officially recommended best practices. For example, you can use:

  • php artisan serv Start local development environment
  • php artisan tinker Local Playground
  • php artisan migrate Perform database migrations, etc.

file

Like other frameworks, Ruby on Rails provides us with rails, and Django provides us with manage.py. I think excellent frameworks will provide a series of Dev Tools to help developers better control it, except for superior frameworks like Spring.

Next, we will try to build a simple course system, in which there are teachers (Teacher), students (Student), and courses (Course), covering simple one-to-one, one-to-many, many-to-many relationships, which are also common in daily development.

I will follow the best practices as I understand them, step by step, to implement a complete CURD; but I won't throw out the various excellent components of Laravel right away. Instead, I will try to understand why a component is designed this way and what advantages it has over other frameworks when encountering it. This article will not contain all the code, but you can still see how I built it step by step through the commit history of this repository godruoyi/laravel-best-practice.

Make Model #

Our first step is to generate the corresponding Model based on the Artisan command provided by Laravel; In actual development, we usually provide additional parameters to generate additional template files when generating models, such as database migration files, test files, Controllers, and so on; We will also use make:model to generate a CURD Controller for Course. I have listed the relevant commits below, and I have tried to keep each commit as small as possible:

Once the relationships between models are defined, I believe that 50% of the entire development task has been completed. This is because we have already defined the fields in the data table, the relationships between tables, and most importantly, how to write the data and their relationships into the database. Below is a brief introduction of how this is accomplished in Laravel.

Database Migration

Laravel's Migration provides a convenient API for defining the majority of database and table fields. The complete definition of Migration retains the entire migration history of the application. With these files, we can quickly rebuild our database design in any new location. All database changes are completed through migration, which is also one of Laravel's recommended best practices.

Laravel Migration also provides a rollback mechanism, which can rollback the most recent database change. However, I do not recommend doing this in a production environment; database migrations in a production environment should always be forward rolling and should not include rollback operations. For example, if you mistakenly set an index for a table in the previous change operation, the correct approach, as I understand it, is not to rollback, but to create a new migration file and ALTER the previous changes in the new migration file.

A modern framework should have Migration. Below is the definition of Course and the intermediate table:

Schema::create('courses', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('description')->nullable();
    $table->unsignedBigInteger('teacher_id')->index();
    $table->text('content')->nullable();
    $table->timestamps();
});

// create pivot table Schema::create('course_student', function (Blueprint $table) { $table->unsignedBigInteger('course_id')->index(); $table->unsignedBigInteger('student_id')->index(); $table->timestamps(); $table->primary(['course_id', 'student_id']); });

Model Relationship

Laravel's another strength lies in its ability to abstract the relationship between 'models and models' through Eloquent; for example, in the following definition, we describe a Course that can have multiple Students and one Teacher, and a Student that may have multiple Courses.

// Models/Course.php
public function students(): BelongsToMany
{
    return $this->belongsToMany(Student::class);
}

public function teacher(): hasOne { return $this->hasOne(Teacher::class); }

Once the relationships between the models are defined, we can easily query their data relationships through Laravel Eloquent. Laravel will automatically handle complex join operations for us, and can also help us deal with N+1 problems under certain conditions. Let's take an example:

$course = Course::with('teacher', 'students')->find(1)

// assert expect($course) ->id->toBe(1) ->students->each->toBeInstanceOf(Student::class) ->teacher->toBeInstanceOf(Teacher::class);

In this example, we queried the course with ID 1 and its associated teachers and students; this will result in 3 SQL operations, including a query across the intermediate table (course_student), and during this process, we don't need to do anything, Laravel will automatically generate the corresponding Join operations based on your model definition.

select * from "courses" where "id" = 1

select * from "teachers" where "teachers"."id" in (5)

select "students".*, "course_student"."course_id" as "pivot_course_id", "course_student"."student_id" as "pivot_student_id" from "students" inner join "course_student" on "students"."id" = "course_student"."student_id" where "course_student"."course_id" in (1)

How to save data to database

Laravel Factory provides a great way to mock test data. Once we define the Factory rules for the Model, we can easily simulate a relationship-complete data in the development phase. This is much more convenient and reliable than manually creating test data for the frontend, as in the following example, which assigns a teacher and an uncertain number of students to each course:

// database/seeders/CourseSeeder.php
$students = Student::all();
$teachers = Teacher::all();

Course::factory()->count(10)->make()->each(function ($course) use ($students, $teachers) { $course->teacher()->associate($teachers->random()); $course->save(); $course->students()->attach($students->random(rand(0, 9))); });

最后我们通过运行 php artisan migrate --seed,Laravel 会自动同步所有的数据库迁移文件并按照 Laravel Factory 定义的规则生成一个关系完备的测试数据。file

Laravel Route #

In Laravel, we can also easily manage the application's routes; Laravel's routes are centralized, and all routes are written in one or two files; Laravel's Route exposes a simple API to developers, and through these APIs, we can easily register a route that conforms to industry standards in a RSETful style, such as the route we register for our course:

Route::apiResource('courses', CourseController::class);

Laravel will automatically register 5 routes for us as shown below, including POST requests for adding operations, DELETE requests for deletion, etc.

file

Although Laravel's routing is a very good design, it is not the most efficient design. Laravel uses an array to store all the routes you have registered; when matching routes, Laravel uses the pathinfo of your current request to match all the registered routes; when you have a huge number of routes, in the worst case, you need O(n) times to find a matching route. However, this complexity is almost negligible compared to the overhead of registering routes and starting services, and an application will not have an excessive number of routes. In addition, Laravel also provides the artisan route:cache command to cache the registration and matching of routes. I guess this is also why Laravel does not need to implement other excellent routing algorithms such as Radix Tree.

Create Course #

Next, let's see how to elegantly save data in Laravel. You can refer to the following commits for this part of the record:

We know that before performing data operations, we need to validate the data first. The FormRequest provided by Laravel can easily achieve this; you can define the validation rules for each field passed from the front end in the FormRequest, such as whether it is required, whether the ID should exist in the database, etc.

class StoreCourseRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
            'content' => 'nullable|string',
            'teacher_id' => 'required|exists:teachers,id',
            'students' => 'nullable|array',
            'students.*' => 'sometimes|int|exists:students,id',
        ];
    }
}

If you try to pass in some invalid data, Laravel will help us validate it directly and return error messages, such as the teacher_id below does not exist in the database.

$ echo -n '{"name": "hello", "teacher_id": 9999}' | http post http://127.0.0.1:8000/api/courses -b
{
    "errors": {
        "teacher_id": [
            "The selected teacher id is invalid."
        ]
    },
    "message": "The selected teacher id is invalid."
}'}

Thanks to Laravel's powerful helper functions and rich API, we can even achieve creating a course and updating dependencies with just one line of code in the following example. However, these are just a few ways to write the word "", and in real development, we should choose what is suitable for the team and easy to understand. But I think it is precisely this pursuit of the ultimate experience that makes everyone who uses Laravel fall in love with it.

// app/Http/Controllers/CourseController.php
public function store(StoreCourseRequest $request)
{
    $course = tap(
        Course::create($request->validated()), 
        fn ($course) => $course->students()->sync($request->students)
    );
return response()->json(compact('course'), 201);

Storage Helper #

In addition to the tap helper function mentioned above, another excellent feature of Laravel is that it provides us with a plethora of helper functions; such as Arr for array manipulation, Str for string manipulation, Collection for collection manipulation, Carbon for time manipulation, and more.

collect(['[email protected]', '[email protected]', '[email protected]'])
    ->countBy(fn ($email) => Str::of($email)->after('@')->toString())
    ->all(); // ['gmail.com' => 2, 'yahoo.com' => 1]

Here is another interesting phenomenon: in Laravel, helper functions are usually placed under a file named Support, while in other frameworks they are usually called utils. In my opinion, if we compare the names, support is much more elegant here; and Laravel's source code is full of this kind of artisan design everywhere; whether it's function naming, comments, or even when to use empty lines, there is design thinking behind it. In the PSR2 coding standard, there is also a specific Laravel formatting style.

After writing code for so long, I don't know if the code I wrote is good enough, but fortunately, I can smell a little bit of bad code, all thanks to Laravel.

For example, you can randomly open a framework's source code file (such as Kernel.php) and examine its naming and method design. I think these skills are universal in all languages.

protected function sendRequestThroughRouter($request)
{
    $this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
$this->bootstrap();
return (new Pipeline($this->app))
            ->send($request)
            ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
            ->then($this->dispatchToRouter());

Testing #

Laravel provides us with another excellent design, which is testing. It offers us a wide variety of tests, including HTTP testing, browser testing (behavior testing), unit testing, database testing, and more. As backend developers, testing should be the most important part of all processes; we may not need to write unit tests for every function, but for every API exposed, there should be enough feature tests to cover most possible scenarios.

In Laravel, we can easily write functional tests for each API, such as the HTTP test we write for creating a course as shown below:

uses(RefreshDatabase::class);

it('create course fails if the teacher is not exist', function () { $course = [ 'name' => 'Laravel', 'description' => 'The Best Laravel Course', 'teacher_id' => 1, // teacher not exist ];

$this->postJson(route('courses.store'), $course)
    ->assertStatus(422)
    ->assertJsonValidationErrors(['teacher_id']);

it('create course successfully with 1 students', function () { Teacher::factory()->create(['name' => 'Godruoyi']); Student::factory()->create(['name' => 'Bob']);

$this->assertDatabaseCount(Teacher::class, 1);
$this->assertDatabaseCount(Student::class, 1);
$course = [
    'name' => 'Laravel',
    'description' => 'The Best Laravel Course',
    'teacher_id' => 1,
    'students' => [1],
];
$this->postJson(route('courses.store'), $course)
    ->assertStatus(201);
expect(Course::find(1))
    ->students->toHaveCount(1)
    ->students->first()->name->toBe('Bob')
    ->teacher->name->toBe('Godruoyi');

file

Update & Select & Delete #

Next, let's see how to implement query/delete/update operations in Laravel. You can refer to the following commits for this part of the record: - feat: create course and related testing- feat: show course and testing- feat: update course and testing- feat: delete course and testing- feat: use laravel resources

public function index(Request $request)
{
    $courses = Course::when($request->name, fn ($query, $name) => $query->where('name', 'like', "%{$name}%"))
        ->withCount('students')
        ->with('teacher')
        ->paginate($request->per_page ?? 10);
return new CourseCollection($course);

public function show(Course $course) { return new CourseResource($course->load('teacher', 'students:id,name')); }

In Laravel, you can efficiently use Eloquent ORM to implement various queries; as in the example above, we use withCount to query the number of students in a course, use with to load the teachers corresponding to the course; you can also specify that the generated SQL query only includes certain fields such as students:id,name. We also use Laravel Resource to format the final output, the reason for this is that in many cases we do not want to directly expose the database fields, you can even display different fields in Laravel Resource according to different roles, as the secret field below only returns when the user is an admin:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'secret' => $this->when($request->user()->isAdmin(), 'secret-value'),
        'created_at' => $this->created_at,
        'updated_at' => $this->updated_at,
    ];
}

Abstract API #

Laravel Another elegant place is to provide developers with many excellent components, such as Cache, Filesystem, Queue, View, Auth, Event, Notifaction, etc. These components all use a common design: that developers only need to face a highly abstract API without worrying about the specific implementation. For example, part of the API definition of the Laravel Cache Store is as follows:

interface Store
{
    public function get($key);
    public function put($key, $value, $seconds);
}

When using Cache, we basically don't need to care whether it's file caching or Redis caching; when using queues, we also don't need to care whether it's a sync queue or a professional QM like Kafka. This is very useful in daily development, because you don't need to configure various complex services locally. You can change your cache driver to local disk and your queue driver to local sync queue in the .env file during the development phase; when you have completed all development, you only need to modify the .env values in the staging/prod environment, and you hardly need to do any extra work.

file

Laravel Core - Container #

Laravel Container is the most core part of the entire Laravel framework, and everything is built on top of it.

We know that the container has only two functions: 1. to bind things 2. to get things from the container. The essence of all frameworks that use containers is to frantically load things into the container when the framework starts. The more things inside the container, the more functionality the container provides. For example, Java's Spring will fill different objects into the Spring Container at compile time, so that different values can be obtained from the container during use. Laravel Container is similar; Laravel also cleverly provides a way to load things into the container through Service Providers, defined as follows:

interface ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
/**
 * Bootstrap any application services.
 */
public function boot(): void

Each Service Provider will set different values in the container during the registration phase; for example, the CacheServiceProvider will register the Cache object in the container. When using Cache::get, the registered Cache object will be used. Values should not be retrieved from the container during the registration phase because the service may not be ready at this time; the startup phase is generally used to control how to start your service, such as connecting to a server or starting the engine. Laravel defaults to registering over 20 Service Providers, each providing a new capability for Laravel, such as Cookie/Session/DB/Filesystem, etc. All of its core components are registered in this way, and it is because of the numerous Service Providers that the Laravel Container becomes more powerful.

I particularly like one thing about the Laravel Container, which is its ability to retrieve any object, even if it doesn't exist in the container, it can create one for you. The Laravel Container automatically constructs objects that do not exist in the container. If the construction of this object also depends on another object, Laravel will attempt to recursively create it. For example:

class A
{
    public function __construct(public B $b) {}
}

class B { public function __construct(public C $c) {} }

class C { public function __construct(public string $name = 'Hello C') }

$a = (new Container())->get(A::class);

expect($a) ->not->toBeNull() ->b->not->toBeNull() ->b->c->not->toBeNull() ->b->c->name->toBe('Hello C');

Because of this feature, in most of Laravel's method parameters, you can inject any number of parameters at will; this is also my favorite point. Laravel will automatically help us get it from the container, and if the container does not exist, it will try to initialize it. In the example of CURD above, the Request object is automatically injected by Laravel, and you can also inject any number of parameters afterwards:

class CourseController extends Controller
{
    public function index(Request $request, A $a, /* arg1, arg2, ... */)
    {
        // ...
    }
}

'```'

Laravel Pipeline #

Laravel's another excellent design is the Pipeline; Laravel's Pipeline runs through the entire framework lifecycle, and it can be said that the entire framework is started in a pipeline. The implementation of Laravel Pipeline is also interesting; we know that in common Pipeline designs, most of them are implemented through for loops, while Laravel uses the simplest yet most complex implementation array_reduce.

We know that array_reduce can string together and execute a set of data, such as:

array_reduce([1, 2, 3], fn($carry, $item) => $carry + $item) // 6

但当 array_reduce 遇到全是闭包的调用时,情况就复杂了:

$pipelines = array_reduce([Middleware1, Middleware2, /* ... */], function ($stack, $pipe) {
    return return function ($passable) use ($stack, $pipe) {
        try {
            if (is_callable($pipe)) {
                return $pipe($passable, $stack);
            } elseif (! is_object($pipe)) {
                [$name, $parameters] = $this->parsePipeString($pipe);
                $pipe = $this->getContainer()->make($name);
            $parameters = array_merge([$passable, $stack], $parameters);
        } else {
            $parameters = [$passable, $stack];
        }
        return $pipe(...$parameters);
    } catch (Throwable $e) {
        return $this->handleException($passable, $e);
    }
})

}, function ($passable) { return $this->router->dispatch($passable); })

// send request through middlewares and then get response $response = $pipelines($request);

The above code is actually the core code of Laravel middleware, and also the core implementation of Laravel's startup process; although it becomes very painful to read due to the addition of various kinds of closures, its essence is actually very simple; it is like wrapping all the middleware like an onion, and then letting the request pass through it layer by layer from the outermost layer, and each layer can decide whether to continue executing downwards, and the final core part is the operation that needs to be executed. For example, we can filter a piece of text in various ways before saving it to the database, such as:

(new Pipeline::class)
    ->send('<p>This is the HTML content of a blog post</p>')
    ->through([
        ModerateContent::class,
        RemoveScriptTags::class,
        MinifyHtml::class,
    ])
    ->then(function (string $content) {
        return Post::create([
            'content' => $content,
            ...
        ]);
    });

Laravel Comnication #

The power of Laravel is inseparable from the support of the community. Over the past decade, the Laravel official has released more than 20 peripheral ecosystems. Here is an excerpt from @白宦成 about the comparison chart of Laravel and other frameworks.

| Project | Laravel | Rails | Django | | --- | --- | --- | --- | | ORM | Yes | Yes | Yes | | Database Migration | Yes | Yes | Yes | | Send Email | Mailables | ActionMailer | SendMail | | Receive Email | No | Action Mailbox | No | | Admin Framework | Nova | No | Django Admin | | Single Page Admin | Folio | No | flatpages | | System Check Framework | Pluse | No | checks | | Sitemap | No | No | Sitemap | | RSS & Atom | No | No | Feed | | Multi-site Framework | No | No | Sites | | Frontend Processing | Asset Bundling | Asset Pipeline | No | | WebSocket | Broadcasting | Action Cable | Django Channels | | Queue | Queues | Active Job | No | | Text Editor | No | Action Text | No | | GIS | No | No | DjangoGIS | | Signal Dispatch Framework | No | No | Signals | | Payment Framework | Cashier | No | No | | Browser Testing | Dusk | No | System Testing | | Automation Deployment Tool | Envoy | No | No | | Redis Scheduler | Horizon | No | No | | Full User System | Jetstream | No | No | | Feature Flag | Pennant | No | No | | Code Style Fixer | Pint | No | No | | Search Framework | Scout | No | No | | OAuth | Socialite | No | No | | System Analysis | Telescope | No | No |

In addition to the official extensions, the community itself has many third-party extensions; there are various Generaters for quickly generating Admin management backends, SpartnerNL/Laravel-Excel for Excel operations, Intervention/image for efficient image operations, as well as Pest, which is about to be included as the default testing framework, and the best WeChat SDK built on top of a crappy API, EasyWechat. You can find almost any wheel you want in the PHP ecosystem.

When it comes to this, we have to mention a powerful presence in the PHP ecosystem, Symfony. Symfony is completely another framework that can rival Laravel, and it is even more advanced in many aspects of design than Laravel; moreover, Laravel's core components such as routing/Request/Container are built on top of Symfony. However, Symfony's promotion is not as successful as Laravel's. Symfony has been released for 12 years now and is still in a lukewarm position (in the domestic context). I guess there is probably no KOL like Taylor Otwell, who is not only good at coding but also at marketing.

It is precisely because of the strong community support that Laravel has become more powerful, and it is also because of this thriving ecosystem that PHP has made it to where it is today. Some developers may think that PHP is on the decline and look down on it. I really don't understand why as engineers we would look down on a particular language. Every language has its natural advantages. As a scripting language, PHP has extremely fast development speed in web development, coupled with low learning curve and not high salary. For startups, isn't it a good choice? I won't think PHP is inferior just because I write Python, nor will I think Go is nothing compared to Rust just because I write Rust. In my opinion, language is just one way to implement a product. Different languages have their own advantages in different fields. We should learn more than one language, understand the advantages and disadvantages of each language as much as possible, and choose the most suitable one for ourselves and our team when developing, instead of thinking that other languages are nothing just because we only know Java.

Insufficient #

Laravel's problem is that it's too slow, with a normal application taking 100-200 ms for a round-trip time (RTT); when faced with slightly larger concurrent requests, the CPU load goes up to 90%. To address the issue of Laravel's slow speed, the Laravel team introduced Laravel/Octane in 2021. If you're interested in Laravel Octane, you can also check out my previous article — Laravel Octane First Experience. With the support of Laravel Octane, we can achieve request response times within 20ms.

However, I think the shortcomings of Laravel do not lie in performance. After all, PHP, as a scripting language, even if we optimize it to the extreme, it is not possible to achieve the same high throughput as Go. If it is really for performance, then why not choose a more suitable language?

In my opinion, the biggest drawback is the heavy community ecosystem; Laravel previously only had the Blade template engine, whose syntax is similar to other template engines and easy to learn; later, Laravel introduced Livewire and Inertiajs. Livewire and Inertiajs are both kinds of frontend frameworks, providing a more efficient way to manage frontend pages and integrate better with Laravel. However, they bring higher learning costs and waste more manpower resources. Originally, we only needed to be familiar with the standard Vue/React API, but now we have to learn a new syntax, which is built on top of the API we are familiar with; sometimes you know how to write the original API, but the new syntax of the new framework forces you to look at more documentation or even source code, and you have to spend more time adapting to it; and when new team members take over these projects, they have to go through the same process as you, and the Laravel team may abandon them one day (such as Laravel-Mix).

Here's another example where Laravel previously launched Laravel Bootcamp to teach newcomers how to quickly get started with Laravel, but before that, only two versions were released, namely Livewire and Inertia. Fortunately, community experts responded in time and later added support for the most basic Blade after that.

Laravel's official also introduced Laravel Sail, Laravel Herd, and the previously deprecated Laravel Homestead as local development tools; while the deployment tools Laravel introduced Laravel Forge, Laravel Vapor, and Laravel Envoyer; if you are a newcomer to Laravel, do you know what to use to set up a local development environment? And how to deploy your Laravel application? To be honest, even after using Laravel for so long, I don't know. I would suggest that if you are interested in Laravel, don't start by dealing with these complex concepts. Instead, install PHP/Nginx/PostgreSQL or Docker locally; and if you also need to write front-end pages, it's better to use native frameworks such as Vue/React/Bootstrap or even Blade.

Laravel still has a very excellent design that I did not point out in this article. If you are interested in Laravel or want to write some good code, I really suggest you take a look at the Laravel source code and its design. I think these designs are universal in all languages. Enjoy.

Reference #

Summary
Laravel is a full-stack web framework that provides components for web development such as routing, middleware, MVC, ORM, and testing. One of its elegant designs is the 'Artisan' command-line interface, which allows developers to interact with Laravel through commands like starting the local development environment, database migration, and generating template files. The article demonstrates building a simple course system in Laravel, starting with creating models using Artisan commands and defining model relationships. It emphasizes the convenience of Laravel's database migration and the power of Eloquent in abstracting model relationships. The article also highlights the benefits of Laravel's features and best practices in development.