Service Classes in Laravel: 10 Open-Source Practical Examples

Service Classes in Laravel: 10 Open-Source Practical Examples
Admin
Monday, September 23, 2024 9 mins to read
Share
Service Classes in Laravel: 10 Open-Source Practical Examples

Service classes are a popular way to extract application logic and keep your code DRY. The best way to learn is to look at practical examples. In this post, we'll examine ten examples from Laravel open-source projects and how they use Services differently.

The article is very long, so here's the Table of Contents:

  1. UserCreationService: Used in Controllers and Artisan Command
  2. IndexCreditCards: Simple Method with Multiple Usage
  3. InvoiceCalculator: Many Methods around Same Topic
  4. CreateProjectLink: Service with Private Methods
  5. InvoiceService Used in Another Service
  6. VisitorService: One "Main" and Two "Extra" Methods
  7. AppointmentService Used in Two Controllers
  8. AuthService to Work with Auth Tokens
  9. CancelAccount with execute(): Similar to Queueable Job
  10. ExchangeRateHost with Interface Contract

All the examples include links to GitHub repositories so you can explore them further. Let's go!


Example 1: UserCreationService: Used in Controllers and Artisan Command

The first example is from an open-source project called pterodactyl/panel. Here, we have a UserCreationService service, which creates a user using the handle() method.

app/Services/Users/UserCreationService.php:

class UserCreationService
{
public function handle(array $data): User
{
if (array_key_exists('password', $data) && !empty($data['password'])) {
$data['password'] = $this->hasher->make($data['password']);
}
 
$this->connection->beginTransaction();
if (!isset($data['password']) || empty($data['password'])) {
$generateResetToken = true;
$data['password'] = $this->hasher->make(str_random(30));
}
 
/** @var \Pterodactyl\Models\User $user */
$user = $this->repository->create(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),
]), true, true);
 
if (isset($generateResetToken)) {
$token = $this->passwordBroker->createToken($user);
}
 
$this->connection->commit();
$user->notify(new AccountCreated($user, $token ?? null));
 
return $user;
}
}

This service method is called in a few places: two Controllers and one Artisan command.

Reusability is one of the benefits of Services.

app/Http/Controllers/Admin/UserController.php:

// ...
 
use Pterodactyl\Services\Users\UserCreationService;
 
class UserController extends Controller
{
public function __construct(
protected AlertsMessageBag $alert,
protected UserCreationService $creationService,
// ...
) {
}
 
public function store(NewUserFormRequest $request): RedirectResponse
{
$user = $this->creationService->handle($request->normalize());
$this->alert->success($this->translator->get('admin/user.notices.account_created'))->flash();
 
return redirect()->route('admin.users.view', $user->id);
}
 
// ...
}

app/Console/Commands/User/MakeUserCommand.php:

use Pterodactyl\Services\Users\UserCreationService;
 
class MakeUserCommand extends Command
{
// ...
 
public function __construct(private UserCreationService $creationService)
{
parent::__construct();
}
 
public function handle()
{
// ...
 
$user = $this->creationService->handle(compact('email', 'username', 'name_first', 'name_last', 'password', 'root_admin'));
 
// ...
}
}

Example 2: IndexCreditCards: Simple Method with Multiple Usage

This example comes from the serversideup/financial-freedom open-source project.

The primary Service method is simple: just getting data from the database.

app/Services/CreditCards/IndexCreditCards.php:

use App\Models\CreditCard;
 
class IndexCreditCards
{
public function index()
{
$creditCards = CreditCard::with('institution')
->with('rules')
->where('user_id', auth()->id())
->orderBy('name', 'ASC')
->get();
 
return $creditCards;
}
}

The benefit is that it is used in multiple Controllers.

app/Http/Controllers/TransactionController.php:

use App\Services\CreditCards\IndexCreditCards;
 
// ...
 
class TransactionController extends Controller
{
public function index( Request $request ): Response
{
return Inertia::render( 'Transactions/Index', [
'group' => 'transactions',
'transactions' => fn () => ( new IndexTransactions() )->execute( $request ),
'groups' => fn () => ( new IndexGroups() )->index( $request ),
'cashAccounts' => fn() => ( new IndexCashAccounts() )->index(),
'creditCards' => fn() => ( new IndexCreditCards() )->index(),
'loans' => fn() => ( new IndexLoans() )->index(),
'filters' => $request->all()
] );
}
 
// ...
}

app/Http/Controllers/AccountController.php:

use App\Services\CreditCards\IndexCreditCards;
 
// ...
 
class AccountController extends Controller
{
public function index( Request $request ): Response
{
return Inertia::render('Accounts/Index', [
'group' => 'accounts',
'cashAccounts' => fn() => ( new IndexCashAccounts() )->index(),
'creditCards' => fn() => ( new IndexCreditCards() )->index(),
'loans' => fn() => ( new IndexLoans() )->index(),
'institutions' => fn () => ( Institution::orderBy('name', 'ASC')->get() ),
]);
}
 
// ...
}

Example 3: InvoiceCalculator: Many Methods around Same Topic

In this example, we have the Bottelet/DaybydayCRM open-source project.

The first two examples were more like Action classes of doing one thing. But a more typical case is a Service class with multiple methods working with the same object or topic, like invoices, in this case.

The InvoiceCalculator service has six methods for invoice calculations.

It's one of the differences between the Service and Action classes: multiple methods instead of just a single handle() or similar. It's one of the most popular lessons in our course How to Structure Laravel Projects.

app/Services/Invoice/InvoiceCalculator.php:

use App\Models\Offer;
use App\Models\Invoice;
use App\Repositories\Tax\Tax;
use App\Repositories\Money\Money;
 
class InvoiceCalculator
{
private $invoice;
 
private $tax;
 
public function __construct($invoice)
{
if(!$invoice instanceof Invoice && !$invoice instanceof Offer ) {
throw new \Exception("Not correct type for Invoice Calculator");
}
$this->tax = new Tax();
$this->invoice = $invoice;
}
 
public function getVatTotal()
{
$price = $this->getSubTotal()->getAmount();
return new Money($price * $this->tax->vatRate());
}
 
 
public function getTotalPrice(): Money
{
$price = 0;
$invoiceLines = $this->invoice->invoiceLines;
 
foreach ($invoiceLines as $invoiceLine) {
$price += $invoiceLine->quantity * $invoiceLine->price;
}
 
return new Money($price);
}
 
public function getSubTotal(): Money
{
$price = 0;
$invoiceLines = $this->invoice->invoiceLines;
 
foreach ($invoiceLines as $invoiceLine) {
$price += $invoiceLine->quantity * $invoiceLine->price;
}
return new Money($price / $this->tax->multipleVatRate());
}
 
public function getAmountDue()
{
return new Money($this->getTotalPrice()->getAmount() - $this->invoice->payments()->sum('amount'));
}
 
public function getInvoice()
{
return $this->invoice;
}
 
public function getTax()
{
return $this->tax;
}
}

For example, InvoicesController uses the Service to get various invoice information.

app/Http/Controllers/InvoicesController.php:

use App\Models\Invoice;
use App\Services\Invoice\InvoiceCalculator;
 
class InvoicesController extends Controller
{
// ...
 
public function show(Invoice $invoice)
{
// ...
 
$invoiceCalculator = new InvoiceCalculator($invoice);
$totalPrice = $invoiceCalculator->getTotalPrice();
$subPrice = $invoiceCalculator->getSubTotal();
$vatPrice = $invoiceCalculator->getVatTotal();
$amountDue = $invoiceCalculator->getAmountDue();
 
// ...
}
 
// ...
}

Example 4: CreateProjectLink: Service with Private Methods

In this example, we have an open-source project named officelifehq/officelife.

This Service has an execute() method and four private methods where all the logic is done. All the validation rules have also been added to this service.

app/Services/Company/Project/CreateProjectLink.php:

use Carbon\Carbon;
use App\Jobs\LogAccountAudit;
use App\Services\BaseService;
use App\Models\Company\Project;
use Illuminate\Validation\Rule;
use App\Models\Company\ProjectLink;
use App\Models\Company\ProjectMemberActivity;
 
class CreateProjectLink extends BaseService
{
protected array $data;
 
protected ProjectLink $projectLink;
 
protected Project $project;
 
public function rules(): array
{
return [
'company_id' => 'required|integer|exists:companies,id',
'author_id' => 'required|integer|exists:employees,id',
'project_id' => 'required|integer|exists:projects,id',
'type' => [
'required',
Rule::in([
'slack',
'email',
'url',
]),
],
'label' => 'nullable|string|max:255',
'url' => 'required|string|max:255',
];
}
 
public function execute(array $data): ProjectLink
{
$this->data = $data;
$this->validate();
$this->createLink();
$this->logActivity();
$this->log();
 
return $this->projectLink;
}
 
private function validate(): void
{
$this->validateRules($this->data);
 
$this->author($this->data['author_id'])
->inCompany($this->data['company_id'])
->asNormalUser()
->canExecuteService();
 
$this->project = Project::where('company_id', $this->data['company_id'])
->findOrFail($this->data['project_id']);
}
 
private function createLink(): void
{
$this->projectLink = ProjectLink::create([
'project_id' => $this->data['project_id'],
'label' => $this->valueOrNull($this->data, 'label'),
'type' => $this->data['type'],
'url' => $this->data['url'],
]);
}
 
private function logActivity(): void
{
ProjectMemberActivity::create([
'project_id' => $this->project->id,
'employee_id' => $this->author->id,
]);
}
 
private function log(): void
{
LogAccountAudit::dispatch([
'company_id' => $this->data['company_id'],
'action' => 'project_link_created',
'author_id' => $this->author->id,
'author_name' => $this->author->name,
'audited_at' => Carbon::now(),
'objects' => json_encode([
'project_link_id' => $this->projectLink->id,
'project_link_name' => $this->projectLink->label,
'project_id' => $this->project->id,
'project_name' => $this->project->name,
]),
])->onQueue('low');
}
}

This service is used in several places, one of which is ProjectController.

app/Http/Controllers/Company/Company/Project/ProjectController.php:

use App\Services\Company\Project\CreateProjectLink;
 
// ...
 
class ProjectController extends Controller
{
// ...
 
public function createLink(Request $request, int $companyId, int $projectId): JsonResponse
{
$loggedEmployee = InstanceHelper::getLoggedEmployee();
$company = InstanceHelper::getLoggedCompany();
 
$data = [
'company_id' => $company->id,
'author_id' => $loggedEmployee->id,
'project_id' => $projectId,
'type' => $request->input('type'),
'label' => ($request->input('label')) ? $request->input('label') : null,
'url' => $request->input('url'),
];
 
$link = (new CreateProjectLink)->execute($data);
 
return response()->json([
'data' => [
'id' => $link->id,
'type' => $link->type,
'label' => $link->label,
'url' => $link->url,
],
], 201);
}
 
// ...
}

We can also look at how this service is tested.

tests/Unit/Services/Company/Project/CreateProjectLinkTest.php:

use Illuminate\Validation\ValidationException;
use App\Services\Company\Project\CreateProjectLink;
 
class CreateProjectLinkTest extends TestCase
{
// ...
 
/** @test */
public function it_fails_if_wrong_parameters_are_given(): void
{
$request = [
'first_name' => 'Dwight',
];
 
$this->expectException(ValidationException::class);
(new CreateProjectLink)->execute($request);
}
 
// ...
}

There are more tests. You can check them in the GitHub repository.


Example 5: InvoiceService Used in Another Service

In this example, we have codenteq/laerx open-source project and an InvoiceService class with two methods for creating and deleting invoices.

app/Services/Admin/InvoiceService.php:

use App\Http\Requests\Admin\CompanyRequest;
use App\Models\Invoice;
use App\Models\Package;
use App\Models\PaymentPlan;
use Carbon\Carbon;
 
class InvoiceService
{
private $price = null;
 
private $month = null;
 
private $packageId = null;
 
public function __construct()
{
$request = request();
if ($request->planId) {
$plan = PaymentPlan::find($request->planId);
$package = Package::where('planId', $request->planId)->first();
$this->price = $package->price;
$this->packageId = $package->id;
$this->month = $plan->month;
}
}
 
public function store(CompanyRequest $request, $id): void
{
Invoice::create([
'price' => $this->price,
'start_date' => $request->start_date,
'end_date' => Carbon::create($request->start_date)->addMonths($this->month),
'packageId' => $this->packageId,
'companyId' => $id,
]);
}
 
public function destroy($id): void
{
Invoice::where('companyId', $id)->delete();
}
}

The Service usage can be found in another Service, CompanyInfoService.

app/Services/Admin/CompanyInfoService.php:

use App\Http\Requests\Admin\CompanyRequest;
use App\Jobs\ImageConvertJob;
use App\Models\CompanyInfo;
use App\Services\ImageConvertService;
 
class CompanyInfoService
{
private $invoiceService;
 
protected $convertService;
 
public function __construct(InvoiceService $invoiceService, ImageConvertService $convertService)
{
$this->invoiceService = $invoiceService;
$this->convertService = $convertService;
}
 
public function store(CompanyRequest $request, $id): void
{
// ...
 
$this->invoiceService->store($request, $id);
}
 
// ...
 
public function destroy($id): void
{
CompanyInfo::where('companyId', $id)->delete();
$this->invoiceService->destroy($id);
}
}

Example 6: VisitorService: One "Main" and Two "Extra" Methods

In this example, we have a realodix/urlhub open-source project and a VisitorService service, which has a create() method and two methods for other logic.

use App\Helpers\Helper;
use App\Models\Url;
use App\Models\User;
use App\Models\Visit;
use Spatie\Url\Url as SpatieUrl;
 
class VisitorService
{
public function __construct(public User $user) {}
 
public function create(Url $url)
{
$logBotVisit = config('urlhub.track_bot_visits');
$device = Helper::deviceDetector();
$referer = request()->header('referer');
 
if ($logBotVisit === false && $device->isBot() === true) {
return;
}
 
Visit::create([
'url_id' => $url->id,
'visitor_id' => $this->user->signature(),
'is_first_click' => $this->isFirstClick($url),
'referer' => $this->getRefererHost($referer),
]);
}
 
public function isFirstClick(Url $url): bool
{
$hasVisited = $url->visits()
->whereVisitorId($this->user->signature())
->exists();
 
return $hasVisited ? false : true;
}
 
public function getRefererHost(?string $value): ?string
{
if ($value === null) {
return null;
}
 
$referer = SpatieUrl::fromString($value);
 
return $referer->getScheme() . '://' . $referer->getHost();
}
}

This service can be found in the UrlRedirectController controller.

app/Http/Controllers/UrlRedirectController.php:

use App\Models\Url;
use App\Services\UrlRedirection;
use App\Services\VisitorService;
use Illuminate\Support\Facades\DB;
 
class UrlRedirectController extends Controller
{
public function __invoke(Url $url)
{
return DB::transaction(function () use ($url) {
app(VisitorService::class)->create($url);
 
return app(UrlRedirection::class)->execute($url);
});
}
}

We can also check the Unit test for this service.

tests/Unit/Services/VisitorServiceTest.php:

use App\Services\VisitorService;
use PHPUnit\Framework\Attributes as PHPUnit;
use Tests\TestCase;
 
#[PHPUnit\Group('services')]
class VisitorServiceTest extends TestCase
{
private VisitorService $visitorService;
 
protected function setUp(): void
{
parent::setUp();
 
$this->visitorService = app(VisitorService::class);
}
 
#[PHPUnit\Test]
#[PHPUnit\Group('u-service')]
public function getRefererHost(): void
{
$this->assertSame(null, $this->visitorService->getRefererHost(null));
$this->assertSame(
'https://github.com',
$this->visitorService->getRefererHost('https://github.com/laravel'),
);
$this->assertSame(
'http://urlhub.test',
$this->visitorService->getRefererHost('http://urlhub.test/admin?page=2'),
);
}
}

Example 7: AppointmentService Used in Two Controllers

In this example, we have amitavroy/doctor-app open-source project and an AppointmentService service with a getAppointments() method.

app/Services/AppointmentService.php:

use App\Models\Appointment;
 
class AppointmentService
{
public function getAppointments($today = false, $confirmed = true)
{
return Appointment::query()
->with(['patient' => function ($query) {
$query->select([
'id',
'name',
'weight',
'phone_number',
'patient_id',
]);
}], ['location' => function ($query) {
$query->select([
'location.name',
]);
}])
->when($today, function ($query) {
$query->where('date', now()->format('Y-m-d'));
})
->when($confirmed === true, function ($query) {
$query->where('visited', 1)->where('time', '!=', null);
})
->when($confirmed === false, function ($query) {
$query->where('visited', 0);
})
->orderByDesc('date')
->orderByDesc('id')
->paginate(20);
}
}

This service method is used in the HomeController and AppointmentController Controllers.

app/Http/Controllers/HomeController.php:

use App\Services\AppointmentService;
 
class HomeController extends Controller
{
public function __invoke(AppointmentService $appointmentService)
{
$appointments = $appointmentService->getAppointments(true, false);
 
return Inertia::render('Home')
->with('appointments', $appointments);
}
}

app/Http/Controllers/AppointmentController.php:

use App\Services\AppointmentService;
 
class AppointmentController extends Controller
{
private $appointmentService;
 
public function __construct(AppointmentService $appointmentService)
{
$this->appointmentService = $appointmentService;
}
 
public function index()
{
$appointments = $this->appointmentService->getAppointments(false, false);
return Inertia::render('Appointments')
->with('appointments', $appointments);
}
 
// ...
}

Example 8: AuthService to Work with Auth Tokens

In this example, we have idanieldrew/redact open-source project and an AuthService service with six methods.

Modules/Auth/Services/v2/AuthService.php:

// ...
 
use Module\Auth\Services\AuthService as Service;
 
class AuthService extends Service
{
public function store($request): array
{
$user = $this->model()->create([
'username' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'password' => Hash::make($request->password),
]);
 
$token = $user->createToken('token')->plainTextToken;
 
return [
'status' => 'success',
'code' => Response::HTTP_CREATED,
'message' => 'Successfully registered',
'data' => [
'user' => $user,
'token' => $token,
],
];
}
 
public function login($request): array
{
$user = User::whereEmail($request->email)->first();
 
// Check exist user
if (!$user || !Hash::check($request->password, $user->password)) {
return $this->response(
'error',
Response::HTTP_UNAUTHORIZED,
'invalid email or password',
null
);
}
 
$token = $user->createToken('test')->plainTextToken;
 
return $this->response(
'success',
Response::HTTP_OK,
'Successfully login',
['user' => $user, 'token' => $token]
);
}
 
public function forgetPassword(string $field)
{
$data = filter_var($field, FILTER_VALIDATE_EMAIL) ? 'email' : 'phone';
 
// check exist user
$user = (new UserRepository)->getCustomRow($data, $field);
if (!$user) {
return $this->response('fail', Response::HTTP_UNAUTHORIZED, 'email not found', null);
}
 
$token = Str::random(5);
$request = new stdClass();
$request->token = $token;
$request->data = $data;
$request->field = $field;
$request->type = "$data verified";
(new TokenRepository())->store($user, $request);
 
(new ForgetPassword)->forgetPassword(new ForgetPasswordEmail($user, $token));
 
return $this->response('success', Response::HTTP_OK, 'send token for forgot password', null);
}
 
public function changePsd($request)
{
if (!Hash::check($request->old_password, auth()->user()->password)) {
return $this->response('fail', 400, "mot match", null);
}
 
$user = auth()->user();
$user = tap($user, function ($user) use ($request) {
$user->update([
'password' => Hash::make($request->password)
]);
});
return $this->response('success', 200, "match", new UserResource($user));
}
 
public function verfyToken(string $token)
{
$res = (new TokenRepository)->existToken($token);
 
return $res ?
$this->response('success', '200', 'correct', null) :
$this->response('fail', '404', 'incorrect', null);
}
 
public function verifyHandler(User $user, array $data)
{
return (new StatusRepository)->update($user, $data);
}
 
private function response(string $status, int $code, string $message, $data): array
{
return [
'status' => $status,
'code' => $code,
'message' => $message,
'data' => $data ?: null,
];
}
}

Each service in this project extends some base service where the Model is set.

Modules/Auth/Services/AuthService.php:

use Module\Share\Service\Service;
use Module\User\Models\User;
 
class AuthService implements Service
{
public function model(): \Illuminate\Database\Eloquent\Builder
{
return User::query();
}
}

This AuthService is called in multiple places. One of which, for example, is AuthController.

Modules/Auth/Http/Controllers/v2/AuthController.php:

// ...
 
use Module\Auth\Services\v2\AuthService;
use Module\Share\Contracts\Response\ResponseGenerator;
 
class AuthController extends Controller implements ResponseGenerator
{
private AuthService $service;
 
public function __construct()
{
$this->service = resolve(AuthService::class);
}
 
public function register(RegisterRequest $request)
{
$store = $this->service->store($request);
 
return $this->res($store['status'], $store['code'], $store['message'], $store['data']);
}
 
public function login(LoginRequest $request): \Illuminate\Http\JsonResponse
{
$login = $this->service->login($request);
 
return $this->res($login['status'], $login['code'], $login['message'], $login['data']);
}
 
public function res(string $status, int $code, string|null $message, array|int|ResourceCollection|JsonResource $data = null): JsonResponse
{
return response()->json([
'status' => $status,
'message' => $message,
'data' => ! $data ? null : [
'user' => new UserResource($data['user']),
'token' => $data['token'],
],
], $code);
}
}

Example 9: CancelAccount with execute(): Similar to Queueable Job

In this example, we have a monicahq/monica open-source project and a CancelAccount service, which has an execute() method.

The service also has validation rules, permissions, and one private method for the logic.

app/Domains/Settings/CancelAccount/Services/CancelAccount.php:

use App\Interfaces\ServiceInterface;
use App\Models\Account;
use App\Models\File;
use App\Services\QueuableService;
 
class CancelAccount extends QueuableService implements ServiceInterface
{
public function rules(): array
{
return [
'account_id' => 'required|uuid|exists:accounts,id',
'author_id' => 'required|uuid|exists:users,id',
];
}
 
public function permissions(): array
{
return [
'author_must_belong_to_account',
'author_must_be_account_administrator',
];
}
 
public function execute(array $data): void
{
$this->validateRules($data);
 
$account = Account::findOrFail($data['account_id']);
$this->destroyAllFiles($account);
 
$account->delete();
}
 
private function destroyAllFiles(Account $account): void
{
$vaultIds = $account->vaults()->select('id')->get()->toArray();
 
File::whereIn('vault_id', $vaultIds)->chunk(100, function ($files) {
$files->each(function ($file) {
$file->delete();
});
});
}
}

This service can be found in the CancelAccountController controller.

app/Domains/Settings/CancelAccount/Web/Controllers/CancelAccountController.php:

// ...
 
use App\Domains\Settings\CancelAccount\Services\CancelAccount;
 
class CancelAccountController extends Controller
{
// ...
 
public function destroy(Request $request)
{
if (! Hash::check($request->input('password'), Auth::user()->password)) {
throw new ModelNotFoundException('The password is not valid.');
}
 
$data = [
'account_id' => Auth::user()->account_id,
'author_id' => Auth::id(),
];
 
CancelAccount::dispatch($data);
 
return response()->json([
'data' => route('login'),
], 200);
}
}

Services in this project are treated as Jobs that run in a Queue, which is why they are dispatched. Every Service extends the QueuableService, which is basically a Job class.

app/Services/QueuableService.php:

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Throwable;
 
abstract class QueuableService extends BaseService implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
 
public int $tries = 1;
 
public function __construct(
public ?array $data = null
) {
if ($data !== null) {
$this->validateRules($data);
}
}
 
public function handle(): void
{
$this->execute($this->data ?? []);
}
 
abstract public function execute(array $data): void;
 
public function failed(Throwable $exception): void
{
//
}
}

Example 10: ExchangeRateHost with Interface Contract

In this example, we have the jigar-dhulla/exchange-rate open-source project and an ExchangeRateHost service with two methods: convert() and getAllowedCurrencies().

app/Services/ExchangeRateHost.php:

// ...
 
use App\Services\Contract\ExchangeRate as ExchangeRateContract;
 
class ExchangeRateHost implements ExchangeRateContract
{
private const API_URL = 'https://api.exchangerate.host';
 
public function convert(string $from, string $to): float
{
$cacheKey = sprintf('%s-%s-%s', __CLASS__, $from, $to);
$ttl = (int) config('services.conversion.ttl', 3600);
return Cache::remember($cacheKey, $ttl, function () use ($from, $to) {
$uri = sprintf('/convert?from=%s&to=%s', $from, $to);
$response = Http::get(self::API_URL . $uri);
$array = $response->json();
if(!$array['success'] ?? false){
Log::error("Error in API Response of Exchange Rate Conversion", [
'class' => __CLASS__,
'from' => $from,
'to' => $to,
'response' => $response->body(),
'status' => $response->status(),
]);
throw new ExchangeRateException("Could not convert from $from to $to");
}
 
return (float) $array['result'];
});
}
 
public function getAllowedCurrencies(): array
{
$cacheKey = sprintf('%s-%s', __CLASS__, 'symbols');
$ttl = (int) config('services.symbols.ttl', 3600);
return Cache::remember($cacheKey, $ttl, function (){
$response = Http::get(self::API_URL . '/symbols');
$array = $response->json();
if(!$array['success'] ?? false){
Log::error("Error in API Response of fetching symbols", [
'class' => __CLASS__,
'response' => $response->body(),
'status' => $response->status(),
]);
throw new ExchangeRateException("Could not fetch symbols");
}
 
return array_keys($array['symbols']);
});
}
}

This Service is used in the Conversion Livewire component.

app/Livewire/Conversion.php:

use App\Services\Contract\ExchangeRate as ExchangeRateContract;
 
class Conversion extends Component
{
// ...
 
public function mount(ExchangeRateContract $service)
{
try {
$symbols = $service->getAllowedCurrencies();
$this->currencies = array_filter($symbols, fn ($symbol) => in_array($symbol, self::ALLOWED_SYMBOLS));
$this->conversions = $this->getConversions();
} catch (ExchangeRateException $e) {
session()->flash('api_error', $e->getMessage());
}
}
 
// ...
 
public function convert(ExchangeRateContract $service)
{
$this->result = null;
$this->validate();
try {
$this->result = $service->convert($this->from, $this->to);
} catch (ExchangeRateException $e) {
session()->flash('api_error', $e->getMessage());
}
 
if($this->result){
ConversionModel::updateOrCreate([
'from' => $this->from,
'to' => $this->to,
'conversion_date' => now()->format('Y-m-d'),
], [
'result' => $this->result,
]);
}
}
 
// ...
}

Interestingly, the Livewire component type-hints the interface of ExchangeRateContract, not the Service class itself.

The place where it's resolved is the AppServiceProvider.

app/Providers/AppServiceProvider.php:

use App\Services\Contract\ExchangeRate as ExchangeRateContract;
use App\Services\ExchangeRateHost;
 
class AppServiceProvider extends ServiceProvider
{
// ...
 
public function boot(): void
{
$this->app->bind(ExchangeRateContract::class, ExchangeRateHost::class);
}
}

The reason for such binding is the possibility of replacing this Service class with a Dummy Service for testing.

That class actually exists, with hard-coded static values:

app/Services/Dummy.php

use App\Services\Contract\ExchangeRate as ExchangeRateContract;
 
class Dummy implements ExchangeRateContract
{
private const ALLOWED_CURRENCIES = [
'EUR',
'INR',
];
 
public function convert(string $from, string $to): float
{
return 90.00;
}
 
public function getAllowedCurrencies(): array
{
return self::ALLOWED_CURRENCIES;
}
}

At the time of writing this article, this Dummy Service isn't actually used in the project, but the whole structure is prepared for such replacement.


Conclusion: Use-Cases for Service Classes

I hope that from the examples above, you get a complete picture of the practice of using it.

But, to re-cap, the primary benefits of Services are:

  • Reusability: if the Service class method is used in multiple Controllers/Commands
  • Topic Logic Separation: multiple methods working with the same object/topic/Model, separating that logic from the Controllers
  • Testability: a few examples showed how easier it becomes to write tests for those Services when their logic is separated
  • Fake/Replacement: the last example showed that you can create a "dummy" Service class for testing purposes, mimicking the same behavior but not touching the actual data