Loading...

This is taking longer than expected.

Coding standards

Services

These are the standards the analyzer applies. Every rule states what it checks and why it exists.

All standards

Purpose

Services exist to communicate with external systems (Stripe, APIs, third-party integrations). When a service exists, it must not contain domain logic that belongs in a model or manage model state directly.
Payment services may call framework SDK methods that perform external billing workflows, such as Laravel Cashier subscription builders, subscription lifecycle methods (newSubscription(), checkout(), cancelNow(), addPriceAndInvoice(), removePrice(), swap()), and payment-method methods (addPaymentMethod(), updateDefaultPaymentMethod(), deletePaymentMethod()). Treat those as external Stripe operations, not ordinary domain model writes, as long as the service is not manually assigning unrelated Eloquent attributes or calling generic persistence methods for app-owned state.

Services are read-only with respect to models

When a service receives a model, it must not call save(), update(), updateQuietly(), or assign attributes on that model. Services read models and communicate results via return values; the observer or caller persists returned data when persistence is needed.
Do not fail Cashier or SDK lifecycle/payment-method calls solely because the SDK also syncs local billing records. Fail generic Eloquent writes (save, update, attribute assignment) to app-owned domain state; allow external-system SDK operations inside services whose purpose is that integration.

// Correct — service returns the Stripe ID, observer assigns it before INSERT
public function creating(BillingPrice $price): void
{
    $price->stripe_price_id = app(StripeSyncService::class)->syncPrice($price);
}

// Incorrect — service writes directly to the model
public function syncPrice(BillingPrice $price)
{
    $result = $this->stripe->prices->create([...]);
    $price->updateQuietly(['stripe_price_id' => $result->id]); // wrong
}

This rule is about persistence, not about what a service may read. A service may read the request, the session, or a cookie when that is its input. CountryDetectionService reads request()->ip() and session(), calls an external geolocation API, and returns a country code that UserObserver assigns to $user->country_code. That is compliant: it reads freely and never writes to a model.

Who persists when there is no observer

The pattern above assumes a model is already being saved: the observer fills a field in before the write completes. Some operations have no such save in flight — the operation is the creation or update of a record. A middleware resolving the current tracking session is one: nothing is being saved, so there is no observer to hand a value to.
In that case the record belongs to the model, through a named method. Eloquent already owns create(), firstOrCreate(), and updateOrCreate(); a domain-named version of that is the natural home. The service is limited to producing the data the model needs.

// Service: reads the request, normalizes the input, returns data
$attributes = static::attributesFromRequest($request);

// Model: owns the record
$trackingSession = TrackingSession::resolveFor($attributes, $storedId, $cookieId);

Do not push the persistence into the caller — a middleware or a controller — to satisfy the rule. That spreads writes further than leaving them in the service.

Services must not contain model domain logic

If a method only queries or operates on Eloquent models without touching an external system, it does not belong in a service — it belongs in the model or a model method.
Input normalization is not model domain logic. Helpers that clean a user agent string or build a click identifier out of a query parameter describe the service's own input, not the model, and stay in the service.
A model method takes primitives — an array of attributes, an id, a value object — never a framework request. If a model method needs Request, the boundary is in the wrong place.

// Correct — model owns the logic
public function cancelSubscriptions()
{
    $this->subscriptions()->each(function ($subscription) {
        $subscription->cancel();
    });
}

// Incorrect — no external system involved, should be a model method
public function cancelSubscriptionsByPrice(BillingPrice $price)
{
    Subscription::whereHas('items', ...)->get()->each->cancel(); // wrong place
}

Call pattern

When Livewire components or controllers need external-system service behavior, they call a model method or observer-owned flow instead of calling the service directly.
Pattern: Livewire / Controller → Model Method → Observer → Service

// Correct — Livewire calls the model method
$price->archive();

// Incorrect — Livewire calls the service directly
app(StripeSyncService::class)->archivePrice($price);

Services communicate results via return values

When a service creates a resource in an external system and an ID must be persisted, the service returns that ID. The caller (observer or model) persists it.
If no caller-owned ID or field needs to be persisted, the service may return a small result payload/DTO such as success, message, action, or url to describe the external workflow outcome. Do not require an ID return for checkout redirects, toggles, cancellations, or other operations where the SDK handles persistence internally.

// Correct
public function syncPlan(BillingPlan $plan)
{
    $product = $this->stripe->products->create([...]);
    return $product->id;
}

// Caller persists the returned ID
private function assignStripeProductId(BillingPlan $plan): void
{
    $stripeProductId = app(StripeSyncService::class)->syncPlan($plan);
    if ($stripeProductId) {
        $plan->stripe_product_id = $stripeProductId;
    }
}
Weblabor Logo Developed by Weblabor