Courses

Build Laravel API for Car Parking App: Step-By-Step

Start/Stop Parking

At a first glance, it's simple: another CRUD-like resource for Parking, and we're done? But here's where you have a lot of room to choose from, how to structure the API endpoints and Controller methods.

  • Should it be /parkings/start and /parkings/stop?
  • Should it be POST /parkings and PUT /parkings?
  • Some other structure?

I posted this question on Twitter - you can read the replies or watch my re-cap on YouTube: To CRUD or Not To CRUD?.

In short, there are at least a few ways to structure this. I would go for a non-standard-CRUD approach and create a ParkingController with start/stop methods.

php artisan make:controller Api/V1/ParkingController

Also, I will use API Resource here, because we would need to return the Parking data in a few places.

php artisan make:resource ParkingResource

We will in that API Resource return only the data we would show on the front-end client:

app/Http/Resources/ParkingResource.php:

class ParkingResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'zone' => [
'name' => $this->zone->name,
'price_per_hour' => $this->zone->price_per_hour,
],
'vehicle' => [
'plate_number' => $this->vehicle->plate_number
],
'start_time' => $this->start_time->toDateTimeString(),
'stop_time' => $this->stop_time?->toDateTimeString(),
'total_price' => $this->total_price,
];
}
}

Important notice: we're converting the start_time and stop_time fields to date-time strings, and we can do that because of the $casts we defined in the model earlier. Also, the stop_time field has a question mark, because it may be null, so we use the syntax stop_time?->method() to avoid errors about using a method on a null object value.

Now, we need to get back to our Model and define the zone() and vehicle() relations. Also, for convenience, we will add two local scopes that we will use later.

app/Models/Parking.php:

class Parking extends Model
{
// ... other code
 
public function zone()
{
return $this->belongsTo(Zone::class);
}
 
public function vehicle()
{
return $this->belongsTo(Vehicle::class);
}
 
public function scopeActive($query)
{
return $query->whereNull('stop_time');
}
 
public function scopeStopped($query)
{
return $query->whereNotNull('stop_time');
}
}

Now, let's try to start the parking. This is one of the possible implementations.

app/Http/Controllers/Api/V1/ParkingController.php:

namespace App\Http\Controllers\Api\V1;
 
use App\Http\Controllers\Controller;
use App\Http\Resources\ParkingResource;
use App\Models\Parking;
use Illuminate\Http\Request;
 
class ParkingController extends Controller
{
public function start(Request $request)
{
$parkingData = $request->validate([
'vehicle_id' => [
'required',
'integer',
'exists:vehicles,id,deleted_at,NULL,user_id,'.auth()->id(),
],
'zone_id' => ['required', 'integer', 'exists:zones,id'],
]);
 
if (Parking::active()->where('vehicle_id', $request->vehicle_id)->exists()) {
return response()->json([
'errors' => ['general' => ['Can\'t start parking twice using same vehicle. Please stop currently active parking.']],
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
 
$parking = Parking::create($parkingData);
$parking->load('vehicle', 'zone');
 
return ParkingResource::make($parking);
}
}

So, we validate the data, check if there are no started parking with the same vehicle, create the Parking object, load its relationships to avoid the N+1 query problem and return the data transformed by API resource.

Next, we create the API endpoint in the routes.

use App\Http\Controllers\Api\V1\ParkingController;
 
// ...
 
Route::middleware('auth:sanctum')->group(function () {
// ... profile and vehicles
 
Route::post('parkings/start', [ParkingController::class, 'start']);
});

Oh, did I mention we will also use the user_id multi-tenancy here, like in the Vehicles?

Not only that, but in this case, we also auto-set the start_time value.

So, yeah, we generate the Observer:

php artisan make:observer ParkingObserver --model=Parking

app/Observers/ParkingObserver.php:

namespace App\Observers;
 
use App\Models\Parking;
 
class ParkingObserver
{
public function creating(Parking $parking)
{
if (auth()->check()) {
$parking->user_id = auth()->id();
}
$parking->start_time = now();
}
}

Notice: technically, we could not even create a parkings.user_id column in the database, so we would get the user from their vehicle, but in this way, it would be quicker to get the user's parking without loading the relationship each time.

Then we register the Observer.

app/Providers/AppServiceProvider.php:

use App\Models\Parking;
use App\Models\Vehicle;
use App\Observers\ParkingObserver;
use App\Observers\VehicleObserver;
 
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Vehicle::observe(VehicleObserver::class);
Parking::observe(ParkingObserver::class);
}
}

Finally, we add a Global Scope to the model.

app/Models/Parking.php:

use Illuminate\Database\Eloquent\Builder;
 
class Parking extends Model
{
// ...
 
protected static function booted()
{
static::addGlobalScope('user', function (Builder $builder) {
$builder->where('user_id', auth()->id());
});
}
 
}

So, finally, let's try it out and call the endpoint.

Laravel API Parking

Success, we've started the parking!

Next, we need to stop the current parking, right? But first, we need to get the data for it, show it on the screen, and then allow the user to click "Stop".

So we need another endpoint to show() the data.

A new Controller method, reusing the same API resource:

app/Http/Controllers/Api/V1/ParkingController.php:

use App\Http\Resources\ParkingResource;
use App\Models\Parking;
 
class ParkingController extends Controller
{
public function show(Parking $parking)
{
return ParkingResource::make($parking);
}
}

And a new route, using route model binding:

routes/api.php:

Route::middleware('auth:sanctum')->group(function () {
// ... other routes
 
Route::post('parkings/start', [ParkingController::class, 'start']);
Route::get('parkings/{parking}', [ParkingController::class, 'show']);
});

We launch this endpoint with success!

Laravel API Parking

And now, as we have the ID record of the parking that we need to stop, we can create a special Controller method for it:

public function stop(Parking $parking)
{
$parking->update([
'stop_time' => now(),
]);
 
return ParkingResource::make($parking);
}

In the Routes file, another new line:

Route::middleware('auth:sanctum')->group(function () {
// ...
 
Route::post('parkings/start', [ParkingController::class, 'start']);
Route::get('parkings/{parking}', [ParkingController::class, 'show']);
Route::put('parkings/{parking}', [ParkingController::class, 'stop']);
});

And, when calling this API endpoint, we don't need to pass any parameters in the body, the record is just updated, successfully.

So yeah, we've implemented the start and stop parking. But what about the price?

Previous: Get Parking Zones
avatar

Hi Povilas, question regarding your code under start method of ParkingController:

    $parking->load('vehicle', 'zone');

Is it still necessary to use the load function to eager load vehicle and zone? i believe there wont be any N+1 problem knowing that there will only be one vehicle and one zone per parking.

👍 2
avatar

Right, you're probably right, this time I was overly cautious. But better safe than sorry! :)

avatar

This is the first time seeing this syntax. I'm happy to always learn something new in Laravel. It gives the confidence that I am getting far deeper into the framework everyday. Thanks a lot.

avatar

Since there will be only one record, is that the reason that show() and stop() method does not load relationships before passing to resource class like in the start() method ? thanks

avatar
You can use Markdown
avatar

In start method, when we fire this endpoint more one time it will store duplicate records at same parking and zone, so I suggest set a validation when same parking exists if stop time null.

👍 1
avatar

Good catch, didn't think of that validation.

avatar

What would be the best way to implement such validation?

avatar

In the repo it was inplemented, updated this lesson with the code.

avatar

$parkingData validation section exists has table vehicles and the column deleted_at. Need to update migration/ model files to add softDelete.

avatar
You can use Markdown
avatar

I think you put the wrong screenshot "We launch this endpoint with success!" (after this sentence)

avatar

Thanks, another well spotted mistake, changed to the correct screenshot. It's so good to see people actually READ the content :)

avatar
You can use Markdown
avatar

you forgot to add "id" in ParkingResource.php

return [ 'id' => $this->id, ...

avatar

Well spotted! Fixed now.

avatar
You can use Markdown
avatar

Is there difference if we register an observer in AppServiceProvider rather than EventServiceProvider (as in Laravel docs) like you did for Vehicle and Parking observers?

avatar

No difference, any Service Provider is fine.

avatar
You can use Markdown
avatar

Regarding

  Route::get('parkings/{parking}', [ParkingController::class, 'show']);
  Route::put('parkings/{parking}', [ParkingController::class, 'stop']);

Would it be better it we add these constraints for these routes?? Like this

Route::get('parkings/{parking}', [ParkingController::class, 'show'])->whereNumber('parking')
Route::put('parkings/{parking}', [ParkingController::class, 'stop'])->whereNumber('parking')
👍 3
avatar

Yes I think it's a good suggestion, great comment.

avatar
You can use Markdown
avatar
fatima zohra ezzaidani

hi, After creating this : 'exists:vehicles,id,deleted_at,NULL,user_id,'.auth()->id(), for validation Rule, when runing postman , I have this issue: Column not found: 1054 Unknown column 'deleted_at' in 'where clause' (SQL: select count(*) as aggregate from `vehicles`
I need to create deleted_at colum in the parking migration ?

avatar

Pretty sure it was created earlier, with $table->softDeletes();

avatar

Hello! It is ok in the repository, but in the text lesson, not was created.

avatar

Oh right, I see it now. Fixed in the lesson, thanks for the notice!

avatar
You can use Markdown
avatar
fatima zohra ezzaidani

Hi Povilas, I think 'stop_time' => $this->stop_time?->ToDateTimeString(), the ? dont fixe my problem about parse null toDateTime.. this is what postman said: "message": "Call to a member function ToDateTimeString() on null",

but it work on database!

avatar

Weird, it did for me. Well, you can rewrite it with if-statement or ternary then, try something like $this->stop_time->toDateTimeString() ?? null or if-statement

avatar
fatima zohra ezzaidani

Yeah I fix it using Carbon, thank you Povilas!

avatar
You can use Markdown
avatar

It looks like you're using the local scope Parking::active() when checking if there's an active parking in the controller but it's not been added to the model in any previous steps.

avatar

Well noted, thanks! Weird, in the repository scopes are here but perhaps forgot to add them to the lesson text itself. Fixed now!

avatar

If you stop an already stopped parking, it will always update the stop_time. One possible solution would be to apply the scopeStopped (declared but not used) similarly to the scopeActive in start parking.

avatar

Well, someone noticed the same in the next video :)

avatar
You can use Markdown
avatar
Luis Antonio Parrado

Hi Povilas, The syntax

'exists:vehicles,id,deleted_at,NULL,user_id,'.auth()->id(),

applied in ParkingController.php validation calls my attention and i need some explanation.

Why can I use so many parameters separated by commas?, in the documentation is not clear to me.

avatar

This translates to:

value provided exists on vehicles table id column AND deleted_at column has value NULL AND user_id is of currently authenticated user

so you can request to start parking for car you own that is not deleted otherwise request would be invalid

avatar
Luis Antonio Parrado

Are they any way more readable to write this line, using for example the Rule class? I have saw something like

Rule::exists('vehicles')->whereNull('deleted_at') . .  
;
avatar
You can use Markdown
avatar

I have edit the

if (Parking::active()->where('vehicle_id', $request->vehicle_id)->exists())

to

if (Parking::active()->where('vehicle_id', $request->vehicle_id)->whereNull('stop_time')->exists())

to allow the user to park with the same vehicle more than once only after checking out.

avatar
You can use Markdown
avatar

Why do we are checking for authentication in the observers if they're only executed on context where there's always an authenticated user? (Parking creation only happens in this context: accessing the endpoint '/parking/start' unauthenticated results in authentication error).

avatar
You can use Markdown
avatar
You can use Markdown