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
andPUT /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.
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!
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?
Hi Povilas, question regarding your code under start method of ParkingController:
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.
Right, you're probably right, this time I was overly cautious. But better safe than sorry! :)
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.
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
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.Good catch, didn't think of that validation.
What would be the best way to implement such validation?
In the repo it was inplemented, updated this lesson with the code.
$parkingData
validation sectionexists
has tablevehicles
and the columndeleted_at
. Need to update migration/ model files to add softDelete.I think you put the wrong screenshot "We launch this endpoint with success!" (after this sentence)
Thanks, another well spotted mistake, changed to the correct screenshot. It's so good to see people actually READ the content :)
you forgot to add "id" in ParkingResource.php
Well spotted! Fixed now.
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?
No difference, any Service Provider is fine.
Regarding
Would it be better it we add these constraints for these routes?? Like this
Yes I think it's a good suggestion, great comment.
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 ?
Pretty sure it was created earlier, with
$table->softDeletes();
Hello! It is ok in the repository, but in the text lesson, not was created.
Oh right, I see it now. Fixed in the lesson, thanks for the notice!
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!
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-statementYeah I fix it using Carbon, thank you Povilas!
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.Well noted, thanks! Weird, in the repository scopes are here but perhaps forgot to add them to the lesson text itself. Fixed now!
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.
Well, someone noticed the same in the next video :)
Hi Povilas, The syntax
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.
This translates to:
value provided exists on
vehicles
tableid
column ANDdeleted_at
column has valueNULL
ANDuser_id
is of currently authenticated userso you can request to start parking for car you own that is not deleted otherwise request would be invalid
Are they any way more readable to write this line, using for example the
Rule
class? I have saw something likeI have edit the
to
to allow the user to park with the same vehicle more than once only after checking out.
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).