Back to Course |
Laravel Web to Mobile API: Reuse Old Code with Services

Categories API, Service and Tests

In previous lessons, we have covered how to create an API controller, a service to wrap business logic, and write tests for both API and Web controllers.

Let's implement these features for Categories CRUD in a single lesson.


API Controller

Create API Resources and Controller for Category Model.

php artisan make:resource Api/V1/Vendor/CategoryResource
php artisan make:resource Api/V1/Vendor/CategoryCollection
php artisan make:controller Api/V1/Vendor/CategoryController

app/Http/Controllers/Api/V1/Vendor/CategoryController.php

namespace App\Http\Controllers\Api\V1\Vendor;
 
use App\Http\Controllers\Controller;
use App\Http\Requests\Vendor\StoreCategoryRequest;
use App\Http\Requests\Vendor\UpdateCategoryRequest;
use App\Http\Resources\Api\V1\Vendor\CategoryCollection;
use App\Http\Resources\Api\V1\Vendor\CategoryResource;
use App\Models\Category;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
 
class CategoryController extends Controller
{
public function index(Request $request): CategoryCollection
{
$this->authorize('category.viewAny');
 
$categories = Category::when(
$request->boolean('products'),
fn ($q) => $q->with('products')
)
->where('restaurant_id', auth()->user()->restaurant->id)
->get();
 
return new CategoryCollection($categories);
}
 
public function store(StoreCategoryRequest $request): CategoryResource
{
$restaurant = $request->user()->restaurant;
$category = $restaurant->categories()->create($request->validated());
 
return new CategoryResource($category);
}
 
public function show(Category $category)
{
$this->authorize('category.view');
 
return new CategoryResource($category);
}
 
public function update(UpdateCategoryRequest $request, Category $category): JsonResponse
{
$category->update($request->validated());
 
return (new CategoryResource($category))
->response()
->setStatusCode(Response::HTTP_ACCEPTED);
}
 
public function destroy(Category $category): Response
{
$this->authorize('category.delete');
 
$category->delete();
 
return response()->noContent();
}
}

We have the conditional clause when() in the' index' method. It is helpful for two cases when building an API.

  • To fetch only categories, call the /api/v1/vendor/categories endpoint. Useful for dropdown lists.
  • Adding the products=true parameter returns categories with products. Useful to display the whole menu. Example: /api/v1/vendor/categories?products=true.
$categories = Category::when(
$request->boolean('products'),
fn ($q) => $q->with('products')
) // ...

You can check more on Conditional Clauses on the Laravel documentation. Also, we have another Less Known Conditional Queries tutorial.

Now create new API routes file for the vendor.

routes/api/v1/vendor.php

use App\Http\Controllers\Api\V1\Vendor\CategoryController;
use Illuminate\Support\Facades\Route;
 
Route::group([
'prefix' => 'vendor',
'as' => 'vendor.',
'middleware' => ['auth:sanctum'],
], function () {
Route::apiResource('categories', CategoryController::class);
});

And include it in the main api.php file.

routes/api.php

// ...
 
include __DIR__ . '/api/v1/admin.php';
include __DIR__ . '/api/v1/vendor.php';

Service

Create a new CategoryService class.

app/Services/CategoryService.php

namespace App\Services;
 
use App\Models\Category;
use App\Models\Restaurant;
use Illuminate\Support\Collection;
 
class CategoryService
{
public function getRestaurantCategories(bool $withProducts = false): Collection
{
return Category::when(
$withProducts,
fn ($q) => $q->with('products')
)
->where('restaurant_id', auth()->user()->restaurant->id)
->get();
}
 
public function createCategory(Restaurant $restaurant, array $attributes): Category
{
return $restaurant->categories()->create($attributes);
}
 
public function updateCategory(Category $category, array $attributes): Category
{
$category->update($attributes);
 
return $category;
}
 
public function deleteCategory(Category $category): void
{
$category->delete();
}
}

Update API Controller

Inject the service into the API controller.

app/Http/Controllers/Api/V1/Vendor/CategoryController.php

use App\Http\Resources\Api\V1\Vendor\CategoryCollection;
use App\Http\Resources\Api\V1\Vendor\CategoryResource;
use App\Models\Category;
use App\Services\CategoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
 
class CategoryController extends Controller
{
public function __construct(
public CategoryService $categoryService
) {
}
 
// ...

And update the controller's methods to consume service.

app/Http/Controllers/Api/V1/Vendor/CategoryController.php

public function index(Request $request): CategoryCollection
{
$this->authorize('category.viewAny');
 
$categories = Category::when(
$request->boolean('products'),
fn ($q) => $q->with('products')
)
->where('restaurant_id', auth()->user()->restaurant->id)
->get();
 
return new CategoryCollection($categories);
return new CategoryCollection($this->categoryService->getRestaurantCategories(
withProducts: $request->boolean('products')
));
}
 
public function store(StoreCategoryRequest $request): CategoryResource
{
$restaurant = $request->user()->restaurant;
$category = $restaurant->categories()->create($request->validated());
$category = $this->categoryService->createCategory(
$request->user()->restaurant,
$request->validated()
);
 
return new CategoryResource($category);
}
 
public function update(UpdateCategoryRequest $request, Category $category): JsonResponse
{
$category->update($request->validated());
$this->categoryService->updateCategory(
$category,
$request->validated()
);
 
return (new CategoryResource($category))
->response()
->setStatusCode(Response::HTTP_ACCEPTED);
}
 
public function destroy(Category $category): Response
{
$this->authorize('category.delete');
 
$category->delete();
$this->categoryService->deleteCategory($category);
 
return response()->noContent();
}

Update Web Controller

We update the web CategoryController in the same way.

app/Http/Controllers/Vendor/CategoryController.php

use App\Http\Requests\Vendor\StoreCategoryRequest;
use App\Http\Requests\Vendor\UpdateCategoryRequest;
use App\Models\Category;
use App\Services\CategoryService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
 
class CategoryController extends Controller
{
public function __construct(
public CategoryService $categoryService
) {
}
 
// ...

app/Http/Controllers/Vendor/CategoryController.php

public function store(StoreCategoryRequest $request): RedirectResponse
{
$request->user()->restaurant->categories()->create($request->only('name'));
$this->categoryService->createCategory(
$request->user()->restaurant,
$request->validated()
);
 
return to_route('vendor.menu')
->withStatus('Product Category created successfully.');
}
 
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
$category->update($request->only('name'));
$this->categoryService->updateCategory(
$category,
$request->validated()
);
 
return to_route('vendor.menu')
->withStatus('Product Category updated successfully.');
}
 
public function destroy(Category $category)
{
$category->delete();
$this->categoryService->deleteCategory($category);
 
return to_route('vendor.menu')
->withStatus('Product Category deleted successfully.');
}

And the MenuController.

app/Http/Controllers/Vendor/MenuController.php

namespace App\Http\Controllers\Vendor;
 
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Services\CategoryService;
use Inertia\Inertia;
use Inertia\Response;
 
class MenuController extends Controller
{
public function __construct(
public CategoryService $categoryService
) {
}
 
public function index(): Response
{
$this->authorize('category.viewAny');
 
return Inertia::render('Vendor/Menu', [
'categories' => Category::query()
->where('restaurant_id', auth()->user()->restaurant->id)
->with('products')
->get(),
'categories' => $this->categoryService->getRestaurantCategories(
withProducts: true
),
]);
}
}

Tests

Finally, we can add tests for API endpoints.

php artisan make:test Api/CategoryTest

tests/Feature/Api/CategoryTest.php

namespace Tests\Feature\Api;
 
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class CategoryTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_vendor_can_view_categories(): void
{
$vendor = $this->getVendorUser();
$response = $this
->actingAs($vendor)
->getJson(route('api.vendor.categories.index'));
 
$response->assertOk();
}
 
public function test_vendor_can_store_category(): void
{
$vendor = $this->getVendorUser();
$response = $this
->actingAs($vendor)
->postJson(route('api.vendor.categories.store'), [
'name' => 'Pizzas',
]);
 
$response->assertCreated();
}
 
public function test_vendor_can_update_category(): void
{
$vendor = $this->getVendorUser();
$category = $vendor->restaurant->categories()->first();
 
$response = $this
->actingAs($vendor)
->putJson(route('api.vendor.categories.update', $category), [
'name' => 'New Category Name',
]);
 
$response->assertAccepted();
}
 
public function test_vendor_can_destroy_category(): void
{
$vendor = $this->getVendorUser();
$category = $vendor->restaurant->categories()->first();
 
$response = $this
->actingAs($vendor)
->deleteJson(route('api.vendor.categories.destroy', $category));
 
$response->assertNoContent();
}
}

And add tests for the web controller.

php artisan make:test Web/CategoryTest

tests/Feature/Web/CategoryTest.php

namespace Tests\Feature\Web;
 
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
use Tests\Traits\WithTestingSeeder;
 
class CategoryTest extends TestCase
{
use RefreshDatabase;
use WithTestingSeeder;
 
public function test_vendor_can_view_categories(): void
{
$vendor = $this->getVendorUser();
$response = $this
->actingAs($vendor)
->get(route('vendor.menu'));
 
$response->assertInertia(function (Assert $page) {
return $page->component('Vendor/Menu')
->has('categories');
});
}
 
public function test_vendor_can_view_categories_create(): void
{
$vendor = $this->getVendorUser();
$response = $this
->actingAs($vendor)
->get(route('vendor.categories.create'));
 
$response->assertInertia(function (Assert $page) {
return $page->component('Vendor/Categories/Create');
});
}
 
public function test_vendor_can_store_category(): void
{
$vendor = $this->getVendorUser();
$response = $this
->actingAs($vendor)
->postJson(route('vendor.categories.store'), [
'name' => 'Pizzas',
]);
 
$response->assertRedirectToRoute('vendor.menu');
}
 
public function test_vendor_can_view_categories_edit(): void
{
$vendor = $this->getVendorUser();
$category = $vendor->restaurant->categories()->first();
 
$response = $this
->actingAs($vendor)
->get(route('vendor.categories.edit', $category));
 
$response->assertInertia(function (Assert $page) {
return $page->component('Vendor/Categories/Edit');
});
}
 
public function test_vendor_can_update_category(): void
{
$vendor = $this->getVendorUser();
$category = $vendor->restaurant->categories()->first();
 
$response = $this
->actingAs($vendor)
->putJson(route('vendor.categories.update', $category), [
'name' => 'New Category Name',
]);
 
$response->assertRedirectToRoute('vendor.menu');
}
 
public function test_vendor_can_destroy_category(): void
{
$vendor = $this->getVendorUser();
$category = $vendor->restaurant->categories()->first();
 
$response = $this
->actingAs($vendor)
->deleteJson(route('vendor.categories.destroy', $category));
 
$response->assertRedirectToRoute('vendor.menu');
}
}

If you did everything correctly, all tests should pass.

php artisan test --filter CategoryTest
 
PASS Tests\Feature\Api\CategoryTest
vendor can view categories 1.17s
vendor can store category 0.24s
vendor can update category 0.25s
vendor can destroy category 0.23s
 
PASS Tests\Feature\Web\CategoryTest
vendor can view categories 0.36s
vendor can view categories create 0.25s
vendor can store category 0.25s
vendor can view categories edit 0.35s
vendor can update category 0.21s
vendor can destroy category 0.23s
 
Tests: 10 passed (33 assertions)
Duration: 3.62s