Explaining the code on the "Go with PHP" website

May 15, 2023 9 min read

On Go with PHP, I presented code that places an order in a PHP-based system. The goal was to show how the language has evolved over time for those who hadn't seen PHP code in a long time. Also, to demonstrate how a modern framework, such as Laravel, improves the developer experience by providing a number of components that abstract a variety of operations under the hood.

This code, however, can be refactored to become more performant and less error prone. So, in this post, I'm going to share with you a breakdown of how the code runs while refactoring it to make it more efficient.

First, the controller that houses this code looks like this:

class OrdersController extends Controller
{
    public function store(Request $request)
    {
        $user = $request->user();
    }
}

The controller action, the store() method, is attached to a route that looks like this:

Route::post('/orders', [OrdersController::class, 'store'])->middleware('auth');

The route registers a handler for a POST request to the /orders endpoint. The handler is the store method of the OrdersController class.

The route also attaches an auth middleware to the endpoint. This built-in Laravel middleware ensures a user is authenticated. If not, it'll respond with a status code of 401 along with an error message without invoking the handler.

The format of the response from the middleware depends on the Accept HTTP header. If the client expects JSON, the response will be in JSON and will look like this:

{
    "message": "Unauthenticated."
}

If the client expects HTML, the middleware will redirect the user to a /login endpoint to authenticate.

Now, inside the controller method, we extract the user object from the request:

$user = $request->user();

The user object is resolved from the Laravel dependency container, which registers the object as a singleton while authenticating the request.

Using this user instance, we can check if the user is unauthorized to place orders, abort the request and respond with a 403 status code.

if ($user->cannot('place-order', Order::class)) {
    abort(403);
}

This code can be simplified using the abort_if Laravel helper to this:

abort_if($user->cannot('place-order', Order::class), 403);

The authorization logic is placed inside a Policy that looks like this:

class OrderPolicy
{
    public function placeOrder(User $user)
    {
        return $user->can_place_orders;
    }
}

Inside the policy class, you may place as many methods as you want. Each method represents an ability you want to check against. In other words, each method represents a challenge for the user object to pass in order to allow it to perform an action.

Laravel decided that it should look into a OrderPolicy class because we provided the class name of our order model (Order::class).

Validation

Now let's inspect the request input validation part:

$validated = $request->validate([
    'vendor_id' => ['required', Rule::exists('vendors', 'id')],
    'products' => ['required', 'array'],
    'products.*.product_id' => [
        'required',
        Rule::exists('products', 'id')->where('vendor_id', $request->vendor_id)
    ],
    'products.*.quantity' => ['required', 'integer', 'min:1']
]);

The validate method accepts an array of rules, applies the rules on the input and aborts the request if any of the rules weren't met.

When the validation fails, the response will contain all the error messages in a format that respects the ACCEPT request header.

If you read the rules for each attribute, they are self explanatory.

The Rule::exists() method here defines a rule that ensures the input matches a database record in a specific database table.

Rule::exists('vendors', 'id')

This one, for example, ensures the vendor_id input matches the id of a record in the vendors database table.

We see a similar validation rule there that ensures all provided product IDs exist in the database:

'products.*.product_id' => [
    Rule::exists('products', 'id')->where('vendor_id', $request->vendor_id)
]

This rule will not only verify that the provided product ID exists in the products table, but also that the records have a vendor_id matching the vendor_id provided with the request.

That way, we ensure the provided products belong to the vendor the order is for.

While using $request->vendor_id here, Laravel checks if the request input has a vendor_id attribute and retrieves it. I know, it raises some red flags. For that, we can replace it with a more explicit input() method call.

$request->input('vendor_id');

Anyway, this check will send an SQL query for each product in the input array. It's not efficient, so we remove it and do our own custom validation.

$validated = validator($request->all(), [
    'vendor_id' => ['required', Rule::exists('vendors', 'id')],
    'products' => ['required', 'array'],
    'products.*.product_id' => ['required'],
    'products.*.quantity' => ['required', 'integer', 'min:1']
])->after(function (Validator $validator) use ($request) {
    // Custom validation logic
})->validate();

We replace the $request->validate() call with a call to the validator() helper method, call the after() method on the returned validator instance, and finally call the validate() method to perform the actual validation.

The validator method accepts the request input from the $request->all() method, in addition to an and array of rules.

The after method accepts a closure that will get invoked after the validation rules are checked. It allows us to run any custom validation logic after the initial logic runs.

Inside the closure, we have this logic:

$ids = collect($request->input('products.*.product_id'));

$products = Product::whereIn('id', $ids)
            ->where('vendor_id', $request->input('vendor_id'))
            ->pluck('id');

$ids->diff($products)->each(function ($id, $i) use ($validator, $products) {
    $validator->errors()->add("products.$i.id", "Invalid Product");
});

We first collect all provided product_ids from the request input and put them into a Collection object. The collect() helper does that.

Then we query the database for products that match the provided IDs and belong to the vendor.

And finally, we check the IDs that weren't found in the database and add an error message to the validator object, which will be returned to the client in the response.

This part can be shortened into this using an arrow function:

$ids->diff($products)->each(
    fn ($id, $i) => $validator->errors()
                              ->add("products.$i.id", "Invalid Product")
);

But, what if the vendor_id or products.*.product_ids attributes weren't provided in the request input? Errors will be added to the validator instance and the request will be aborted. However, the closure inside the after method will still run.

We need to ensure the logic can handle the absence of these attributes:

$ids = collect($request->input('products.*.product_id'));

if ($validator->errors()->hasAny('products.*', 'products', 'vendor_id')){
    return;
}

$products = Product::whereIn('id', $ids)
            ->where('vendor_id', $request->input('vendor_id'))
            ->pluck('id');

$ids->diff($products)->each(function ($id, $i) use ($validator, $products) {
    $validator->errors()->add("products.$i.id", "Invalid Product");
});

Here, we check if the validator has errors for any of the products or vendor_id attributes and return early.

Returning early means the code after the return point will not run.

Insertion in the database

By now, we have all our input validated and stored in a $validated variable. Let's look into inserting the order along with its products in the database:

$order = DB::transaction(function () use ($user, $validated, $request) {
    $order = $user->orders()->create(Arr::except($validated, ['products']));

    OrderProduct::insert(
        Arr::map(
            $request->input('products'),
            fn ($product) => [...$product, 'order_id' => $order->id]
        )
    );

    return $order;
});

We call the transaction method on the DB facade. If you're not sure what a facade is, it's a shortcut to resolve a certain binding from the dependency container. In that case, we're resolving the database connection binding.

The transaction method informs the database object that we're starting an SQL transaction. All the queries running inside the transaction will be committed together, or rolled back together.

Next, we create an order that belongs to the user. We pass the order attributes from the $validated variable, except for the products attribute.

This will generate a query that looks like this:

insert into `orders` (`vendor_id`, `user_id`, `updated_at`, `created_at`)
              values (?, ?, ?, ?)

Under the hood, Laravel will sanitize the values to ensure they're safe to be sent to the database. The vendor_id is brought from the $validated variable, the user_id is set implicitly from the relationship created between the user object and the order object, and the updated_at and created_at columns are filled automatically by Laravel.

We can be more explicit about the attributes we want to send to the order creation query like this:

$order = $user->orders()->create(['vendor_id' => $validated['vendor_id']]);

We can also decouple the order entity from the user entity by creating an order using the order object itself.

$order = Order::create([
    'vendor_id' => $validated['vendor_id'],
    'user_id' => $user->id
]);

Now for the second query in the transaction, we insert multiple records inside the order_products database table. The mapping in the code adds an order_id attribute to the product_id and quantity attributes and fills it with the order ID coming from the first query.

The following code creates a new array with the contents of the $product array in addition to a new member for the order_id attribute.

[...$product, 'order_id' => $order->id]

The query sent to insert the order products will look like this:

insert into `order_products` (`order_id`, `product_id`, `quantity`)
                      values (?, ?, ?), (?, ?, ?)

We are using OrderProduct::insert() instead of OrderProduct::create() because the latter reads the records after they are inserted in the database. That's an extra query we don't need since we don't want to interact with the products records.

When this transaction commits, we'll have a new row in our orders table, and several rows in our order_products table. One for each product in the request input.

If any of the queries fail, the transaction will be rolled back and nothing will be added to the database. An exception will also be thrown and the request will be aborted sending an error response to the user.

Dispatching jobs to the queue

When a new order is created, the system has to send the order to the vendor for processing. This step can be done in the background without delaying the response to the client. For that reason, we're going to dispatch a job to the queue after the database transaction:

SendOrderToVendor::dispatch($order)->onQueue('orders');

This code dispatches a job named SendOrderToVendor to a queue named orders. A set of workers will be waiting to process these jobs in the background.

Similarly, we will dispatch an event to update the frontend about a new order:

NewOrderPlaced::dispatch($order);

Behind the scenes, we have a listener registered to this event that will update the frontend via a web socket message.

I will not go into details on how to configure queues and web sockets in this post. Maybe another time.

Responding to the user

Finally, we have a simple array returned at the end of the method:

return ['order' => $order];

Laravel will detect that the client expects a JSON response and will convert the array, as well as the order object, to JSON.

The order object will only contain the order details. But, if we want to return the products as well, we can do this:

return ['order' => $order->load('products')];

This will send a query to the database to get all the products related to this order.

All the code

Here's the full code after refactoring and extracting some methods:

class OrdersController extends Controller
{
    public function store(Request $request)
    {
        abort_if($request->user()->cannot('place-order', Order::class), 403);

        $validated = $this->validateOrder($request);

        $order = $this->placeOrder($request, $validated);

        SendOrderToVendor::dispatch($order)->onQueue('orders');

        NewOrderPlaced::dispatch($order);

        return [
            'order' => $order->load('products'),
        ];
    }
}

Then we have the validateOrder method:

private function validateOrder(Request $request)
{
    return validator($request->all(), [
        'vendor_id' => ['required', Rule::exists('vendors', 'id')],
        'products' => ['required', 'array'],
        'products.*.product_id' => ['required'],
        'products.*.quantity' => ['required', 'integer', 'min:1']
    ])->after(
        fn ($validator) => $this->validateProducts($request, $validator)
    )->validate();
}

The validateProducts method:

private function validateProducts(Request $request, Validator $validator)
{
    $ids = collect($request->input('products.*.product_id'));

    if ($validator->errors()->hasAny('products.*', 'products', 'vendor_id')){
        return;
    }

    $products = Product::whereIn('id', $ids)
        ->where('vendor_id', $request->input('vendor_id'))
        ->pluck('id');

    $ids->diff($products)->each(
        fn ($id, $i) => $validator->errors()
                                  ->add("products.$i.id", "Invalid Product")
    );
}

And the placeOrder method:

private function placeOrder(Request $request, array $validated)
{
    return DB::transaction(function () use ($validated, $request) {
        $order = $request->user()->orders()->create(
            Arr::except($validated, ['products'])
        );

        OrderProduct::insert(
            Arr::map(
                $request->input('products'),
                fn ($product) => [...$product, 'order_id' => $order->id]
            )
        );

        return $order;
    });
}

The code is readable and self-explanatory, once you understand the PHP syntax and a few concepts in Laravel. It's also written with performance and security in mind.

Is that all?

What happens if the queue service was down and the SendOrderToVendor job wasn't sent to the queue? The vendor won't know that a new order was placed, and the response will contain an error message so the user may re-submit the order while it's already in the system.

This is a very interesting problem that we will discuss in a future post.


I'm Mohamed Said. I work with companies and teams all over the world to build and scale web applications in the cloud. Find me on twitter @themsaid.


Get updates in your inbox.

Menu

Log in to access your purchases.

Log in

Check the courses I have published.


You can reach me on Twitter @themsaid or email [email protected].


Join my newsletter to receive updates on new content I publish.