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.
/parkings/start
and /parkings/stop
?POST /parkings
and PUT /parkings
?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?