Prismø - Localised App Store Pricing

Return to Projects
Prismø - Localised App Store Pricing

Prismø automatically calculates PPP adjusted prices for your App Store apps across 170+ territories, using exchange rates, Big Mac Index, and World Bank data. It then pushes them directly to App Store Connect in one click. No spreadsheets, no manual pricing matrix.

Project Type
Web App
Current Status
In Progress
App Version
1.0.0 (Build 1)
Development
208 hrs (website & app)

Prismø is a web application that solves a specific, under-served problem for iOS and macOS developers: setting fair, market-appropriate prices across the 175 App Store territories. Most indie developers either set a single price and let Apple do a mechanical currency conversion, or spend hours manually adjusting prices territory by territory in App Store Connect. Neither approach is good.

A straight currency conversion ignores the economic reality that a dollar in the United States does not represent the same purchasing power as the local equivalent in Brazil, India, or Egypt. The result is that apps are unaffordable in lower-income markets and underpriced in higher-income ones.

Purchase Power Parity (PPP) is the economic framework that corrects for this. It compares what a basket of goods costs across countries to produce a multiplier that reflects how far local currency actually goes. Apply that multiplier to your base price, and you get an amount that feels proportionally fair to a customer in each market, rather than a number that is merely the result of a currency lookup.

Prismø automates this entirely. You configure your App Store Connect API credentials, import your apps, select a base price, and Prismø calculates PPP-adjusted prices for every territory and pushes them to Apple's API in one operation. The entire flow from login to first price push can be completed in under five minutes.


The Problem in Detail

App Store pricing is harder than it looks. Apple provides 900 discrete price points per territory, and each product type (subscriptions, in-app purchases, paid apps) has a different pricing API with different payload shapes. Generating a PPP-adjusted price requires current exchange rates, PPP conversion factors, and a rounding algorithm that maps a raw calculated value to the nearest Apple-valid price point in the right currency.

None of this is particularly difficult in isolation, but assembling it reliably, keeping the data fresh, and pushing it through Apple's API for all 175 territories is tedious enough that most developers simply do not bother.

Prismø's data pipeline solves the freshness problem by running scheduled jobs: exchange rates refresh daily, Big Mac Index data refreshes monthly (sourced directly from The Economist's published CSV), and World Bank PPP conversion factors refresh monthly. Apple's own price points per territory are synced separately so the rounding step always snaps to a valid price.


Technical Foundation

The application is a Laravel 12 web application. No mobile app, no REST API consumed by a separate frontend framework. The entire interface is server-rendered via Livewire full-page components with Flux UI Pro components for all interactive elements.

The result is a PHP-centric architecture where the frontend is almost entirely Blade templates with reactive Livewire wiring and minimal Alpine.js for purely client-side interactions.

Backend stack:

Technology Version Purpose
PHP 8.4 Runtime
Laravel Framework 12 Application framework
Livewire 4 Full-page components and reactive UI
Laravel Cashier (Stripe) 16 Subscription billing
Laravel Reverb 1 WebSocket server for real-time events
Laravel Socialite 5 GitHub OAuth authentication
Firebase PHP-JWT 7 JWT generation for App Store Connect API (ES256)
prinsfrank/standards 3 ISO country and currency standards data
pulkitjalan/ip-geolocation 8 MaxMind GeoIP for geo-blocking
spatie/laravel-csp 3 Content Security Policy headers
treblle/security-headers 0.0.3 HTTP security header stack

Frontend stack:

Technology Version Purpose
Flux UI Pro 2 Official Livewire component library
Tailwind CSS 4 Utility-first CSS
Vite 7 Asset bundling
Laravel Echo + Pusher JS 2 WebSocket client for Reverb
tsparticles/confetti 3 Payment success animation

Development tools:

Technology Purpose
Pest 4 Testing framework
PHPStan / Larastan 3 Static analysis
Rector 2 Automated refactoring
Laravel Pint Code style enforcement
Laravel Boost MCP server for AI-assisted development
Spatie Ray Debug output visualisation

The application is served locally via Laravel Herd and deployable to any standard PHP 8.4 host. Redis is required for caching (exchange rates, App Store Connect responses). Laravel Reverb provides the WebSocket server for real-time subscription activation events.


Architecture Decisions

Several high-level decisions shaped the entire codebase and are worth understanding before looking at individual features.

No controllers for UI

Every user-facing page is a Livewire full-page component. The only traditional Laravel controllers in the application are for the GitHub OAuth callback and Stripe checkout redirect flows, both of which are genuinely request/response operations with no persistent state. Everything else lives in Livewire components backed by Action classes.

Action classes over fat components

Business logic does not live directly in Livewire components. Each significant operation has a dedicated Action class in app/Actions/. Livewire methods accept these as type-hinted parameters, taking advantage of Livewire 4's automatic dependency injection into component methods. This keeps components thin and makes business logic testable in isolation.

UUID primary keys throughout

Every model uses UUIDs as primary keys via the HasUuids trait. No auto-incrementing integers are exposed in URLs or API payloads. This applies uniformly — users, apps, products, pricing records, countries, and all infrastructure models.

Unguarded models

Model::unguard() is called application-wide. No $fillable or $guarded arrays anywhere. Mass assignment protection is handled at the request and action layer rather than the model layer.

Typed casts for every column

Every model defines a casts() method that explicitly casts every database column, including strings. This provides accurate IDE type information and ensures consistent types throughout the application. Datetime columns are always cast to immutable_datetime, returning CarbonImmutable instances. Monetary values use a custom MoneyInCents cast that converts between integer cents in the database and float values in application code.

Encrypted credentials at rest

App Store Connect API credentials (Key ID, Issuer ID, and the ES256 private key) are stored using Laravel's encrypted cast, which applies AES-256-CBC encryption before writing to the database. Decryption happens only server-side at the moment a credential is used in an API call.


The PPP Calculation Engine

The heart of the application is the PPP pricing calculation. Understanding how it works requires understanding what data it uses and where that data comes from.

Data sources

Three external data sources are combined:

  1. Big Mac Index — Published by The Economist, this compares the price of a Big Mac across roughly 55 countries as a fast, consumer-relevant measure of purchasing power. Prismø fetches it from The Economist's public GitHub repository, which publishes the raw CSV. For countries covered by both the Big Mac Index and World Bank data, the Big Mac Index takes precedence because it updates more frequently.

  2. World Bank PPP conversion factors — Comprehensive price surveys across hundreds of goods and services for around 180 countries. Updated annually. Used for countries not covered by the Big Mac Index.

  3. Exchange rates — Live exchange rates from a financial data provider, refreshed daily. Used to convert PPP-adjusted prices from USD to local currencies.

All three are stored in the system_countries table (dollar_price for PPP-equivalent USD price in each country) and the system_exchange_rates table, refreshed by scheduled Artisan commands.

The calculation

PppCalculatorService takes a base price and a base country code, then iterates every country in the system:

class PppCalculatorService
{
    public function calculate(float $basePrice, string $baseCountryCode): Collection
    {
        $this->setupForCalculation($basePrice, $baseCountryCode);

        if (! $this->baseCurrencyRate || ! $this->baseCountry->dollar_price)
        {
            return $this->results;
        }

        Country::query()
            ->where('iso2', '!=', $baseCountryCode)
            ->where('iso2', '!=', 'EU')
            ->each(fn (Country $country) => $this->processCountry($country));

        return $this->results->sortBy('name')->values();
    }

    private function processCountry(Country $country): void
    {
        if (! $rate = $this->rates[$country->currency] ?? null)
        {
            return;
        }

        $this->results->push(PppResultItemDto::fromCountry(
            name             : $country->name,
            code             : $country->iso2,
            currencyCode     : $country->currency,
            isZeroDecimal    : $country->is_zero_decimal,
            targetDollarPrice: $country->dollar_price,
            baseBigMacUSD    : $this->baseCountry->dollar_price,
            basePrice        : $this->basePrice,
            baseCurrencyRate : $this->baseCurrencyRate,
            rate             : $rate,
            priceRounder     : $this->priceRounder,
        ));
    }
}

For each country, the PppResultItemDto computes:

  • The PPP factor: the ratio of the country's dollar_price to the base country's dollar_price
  • The converted price: base price × PPP factor, converted to local currency using the exchange rate
  • The adjusted price: the converted price rounded to a psychologically friendly number by PriceRounderService
  • The vs exchange rate percentage: how much the PPP price differs from a naive exchange-rate-only conversion

Rows where the PPP price diverges more than 30% from a straight exchange-rate conversion are flagged in the results table so developers can quickly identify territories where PPP makes the biggest difference.

Price rounding

A raw calculated price of €7.83 is not a valid Apple price point. PriceRounderService maps the calculated value to the nearest valid price point from the system_price_points table — Apple's actual pricing grid for each territory. This is essential: pushing an arbitrary decimal to Apple's API will be rejected. Every result in the pricing table is a price Apple will actually accept.


App Store Connect Integration

Communicating with Apple's App Store Connect API requires JWT authentication using ES256 (ECDSA with P-256 and SHA-256). Tokens are signed with the user's private key (.p8 file) and carry a 20-minute expiry.

AppStoreConnectBase is an abstract base class that all App Store Connect services extend. It provides the fluent credential-setting API, JWT generation, and an HTTP client configured with retry logic:

abstract class AppStoreConnectBase
{
    protected const string BASE_URL = 'https://api.appstoreconnect.apple.com';

    public function generateToken(): string
    {
        $now     = time();
        $payload = [
            'iss' => $this->issuerId,
            'iat' => $now,
            'exp' => $now + (20 * 60),
            'aud' => 'appstoreconnect-v1',
        ];

        return JWT::encode($payload, $this->privateKey, 'ES256', $this->keyId);
    }

    protected function client(): PendingRequest
    {
        return Http::withToken($this->generateToken())
            ->acceptJson()
            ->contentType('application/json')
            ->timeout(30)
            ->retry(3, 1000, fn (Throwable $exception): bool =>
                $exception instanceof ConnectionException || (
                    $exception instanceof RequestException &&
                    ($exception->response->status() === 429 || $exception->response->serverError())
                ),
            throw: false);
    }
}

The retry policy covers two cases: connection failures (network timeouts, DNS failures) and rate-limit or server-error responses from Apple's API. Three retries with 1,000ms delays handles the occasional Apple 429 without hammering the API.


Three product types, three different APIs

Apple's API for subscriptions, in-app purchases, and paid apps are not consistent with each other. Each uses different endpoints, different payload shapes, and different price schedule models. The ProductTypeEnum encodes this divergence:

enum ProductTypeEnum: int
{
    case SUBSCRIPTION    = 1;
    case IN_APP_PURCHASE = 2;
    case PAID_APP        = 3;

    public function appleConnectApiPricePointsEndpoint(string $productId): string
    {
        return match ($this) {
            self::SUBSCRIPTION    => "/v1/subscriptions/{$productId}/pricePoints",
            self::IN_APP_PURCHASE => "/v2/inAppPurchases/{$productId}/pricePoints",
            self::PAID_APP        => "/v1/apps/{$productId}/appPricePoints",
        };
    }

    public function appleConnectApiPushPricePayload(
        string $productId,
        string $baseTerritory,
        array $items
    ): PushPricePayload
    {
        return match ($this) {
            self::SUBSCRIPTION    => new SubscriptionPricePayload($productId, $items),
            self::IN_APP_PURCHASE => new InAppPurchasePricePayload($productId, $items, $baseTerritory),
            self::PAID_APP        => new PaidAppPricePayload($productId, $items, $baseTerritory),
        };
    }
}

Each case returns a different payload builder class (SubscriptionPricePayload, InAppPurchasePricePayload, PaidAppPricePayload) that knows the correct structure for the respective Apple endpoint. This keeps the switch logic in one place rather than scattered across the price push operation.


Authentication

Authentication is exclusively via GitHub OAuth using Laravel Socialite. There are no usernames, passwords, or email verification flows. The LoginAction performs an upsert on the users table keyed on github_id:

class LoginAction
{
    public function handle(SocialiteUser $account, Closure $success): mixed
    {
        Auth::login($this->getUser($account));
        Session::regenerate();
        return $success();
    }

    private function getUser(SocialiteUser $account): User
    {
        return User::query()->updateOrCreate(
            ['github_id' => $account->getId()],
            ['name' => $account->getName(), 'email' => $account->getEmail()]
        );
    }
}

This means returning users automatically land on their existing account with all their apps and products intact. No password reset flows, no "link your GitHub account" prompts. The tradeoff is that GitHub is a hard dependency — if a user loses access to their GitHub account, they lose access to Prismø.

A GeoBlockMiddleware is applied to the login and OAuth redirect routes using MaxMind's GeoLite2 database. This prevents sign-up from restricted geographic regions. The database is refreshed weekly by the maxmind:refresh scheduled command.


Onboarding

New users land on a 5-step onboarding wizard before they can access the main application. This is enforced by route middleware: authenticated users without App Store Connect credentials configured are redirected to the onboarding route, while authenticated users with credentials are redirected away from it.

The wizard steps in order:

  1. Welcome — introduction to what Prismø does
  2. Base Country — the country the user prices from (defaults to United States). This drives all PPP calculations and determines which currency the price point picker uses
  3. Private Key — upload or paste the .p8 private key file. The key is extracted, encrypted, and the original file discarded
  4. Credentials — enter the App Store Connect Key ID and Issuer ID
  5. Complete — confirmation and redirect to the dashboard

The credential fields in steps 3 and 4 are stored in the users_settings table with Laravel's encrypted cast. Decryption happens only when AppStoreConnectBase.setPrivateKey() is called immediately before making an API request.

The no-credentials middleware on the onboarding route and has-credentials middleware on the main app routes enforce this flow cleanly. Users cannot reach the apps or pricing pages until they have completed onboarding.


Livewire Architecture

Every page in the main application is a Livewire full-page component. The route definition uses Route::livewire():

Route::middleware(['auth', 'web-app'])->group(function (): void
{
    Route::livewire('/', App\Livewire\Dashboard\Index::class)
        ->name('dashboard')
        ->middleware('has-credentials');

    Route::livewire('apps', App\Livewire\Apps\Index::class)
        ->name('apps.index')
        ->middleware('has-credentials');

    Route::livewire('apps/{app}/{product}', App\Livewire\Pricing\Index::class)
        ->name('apps.pricing')
        ->middleware('has-credentials');
});

Component anatomy

Full-page components follow a consistent structure: mount() for initialisation and authorisation, render() returning the view with a title, #[Computed] attributes for derived data, and #[On] listeners for cross-component events. Actions are injected as method parameters:

class Index extends Component
{
    use HasNotifications;
    use HasPagination;

    public function mount(): void
    {
        $this
            ->setSortableColumns($this->getSortableColumns())
            ->setSortBy('name')
            ->setSortDirection('asc')
            ->setPerPage(25);
    }

    public function render(): View
    {
        return view('livewire.apps.index')
            ->title('Apps');
    }

    #[Computed]
    #[On('refresh-apps')]
    public function apps(): LengthAwarePaginator
    {
        return $this->sortedPaginate(
            auth()->user()
                ->applications()
                ->withCount('products')
        );
    }
}

Shared concerns

Three traits are mixed into components that need them:

  • HasNotifications — provides notifySuccess(), notifyError(), notifyWarning(), notifyInfo() methods that dispatch flash messages
  • HasPagination — a fluent pagination, sorting, and searching API. setSortableColumns(), setSortBy(), setSortDirection(), setPerPage() configure the behaviour; sortedPaginate() applies it to any query
  • HasSessionMessages — session-based flash messages for redirect flows

Modal pattern

Add, delete, and confirmation flows use a consistent modal pattern. The component holds a boolean state property (for example, showDeleteModal), the modal view receives it via a wire binding, and the confirmation action fires the corresponding method. This is consistent across every modal in the application.

Cross-component communication

Components communicate by dispatching named events. For example, after a new app is added via the Apps\Create component, it dispatches a refresh-apps event, which the Apps\Index component listens for via #[On('refresh-apps')] to refetch and redisplay the apps list. This keeps components decoupled without requiring shared state.


Free Tier and Locking

The application has a free tier (1 app, 1 product) and an unlimited paid subscription. The locking mechanism does not delete or hide locked items. Instead, locked apps and products remain fully browsable — users can see their pricing calculations and product lists — but restricted actions are replaced with upgrade prompts.

A is_locked computed attribute on the Application and Product models checks whether the record is beyond the free tier limits for an unsubscribed user:

// Product model
public function getIsLockedAttribute(): bool
{
    $user = $this->application->user;

    if ($user->subscribed()) {
        return false;
    }

    return $user->applications()
        ->whereHas('products', fn ($q) => $q->where('id', '!=', $this->id))
        ->count() >= config('prismo.limits.products');
}

UserModelService provides static helpers that check limits before allowing new apps or products to be added:

class UserModelService
{
    public static function canAddApps(): bool
    {
        if (! ($user = self::getUser()) instanceof User)
        {
            return false;
        }

        if ($user->subscribed())
        {
            return true;
        }

        return $user->applications()->count() < (int) config('prismo.limits.apps');
    }
}

When a previously subscribed user cancels, their apps and products that exceed the free tier limits are locked rather than deleted. They retain full visibility of their data; only the ability to push pricing is restricted. Resubscribing unlocks everything immediately.


Background Processing

Five scheduled Artisan commands keep the reference data fresh:

Command Schedule Purpose
rates:refresh Daily at 01:00 Fetches and stores exchange rates from the configured API
bigmac:refresh 1st of each month, 01:15 Downloads and parses The Economist's Big Mac Index CSV
worldbank:refresh 1st of each month, 01:30 Fetches World Bank PPP conversion factors for all countries
maxmind:refresh Weekly (Monday) at 02:00 Downloads updated MaxMind GeoLite2 IP geolocation database
applications:update Daily at 03:00 Updates app metadata (name, version, icon) via iTunes Search API

A sixth command, job:history:prune, runs monthly to clean up records older than the configured retention period.

Scheduled command monitoring

Every scheduled command execution is recorded in the scheduled_commands and scheduled_commands_runs tables. RecordScheduledTaskStarting, RecordScheduledTaskFinished, and RecordScheduledTaskFailed event listeners fire on the corresponding Laravel schedule lifecycle events. This gives a visible history of when each job last ran, how long it took, and whether it succeeded. The schedule:run command itself is included in the history, so you can verify the scheduler is being called by the server's cron configuration.

Queue job monitoring. Similarly, queue job lifecycle events are captured by RecordJobProcessing, RecordJobProcessed, and RecordJobFailed listeners. Three jobs are used:

  • UpdateApplicationViaItunesJob — fetches updated app name, version, icon URL, platforms, and categories from the iTunes Search API
  • DeleteApplicationImageJob — removes the stored app icon from the filesystem when an app is deleted
  • DeleteAccountJob — handles the cascade of data removal when a user deletes their account

Subscription Billing

Billing uses Laravel Cashier with Stripe as the payment processor. The User model uses the Billable trait. Subscription state is stored in Cashier's subscriptions and subscription_items tables.

The subscription flow is one of the two places where a traditional controller exists. The SubscribeController generates a Stripe Checkout session and redirects the user to Stripe's hosted payment page. After payment, Stripe redirects to SuccessController, which verifies the session token and displays the success state.

Real-time subscription activation

Waiting for a Stripe webhook to fire and redirecting users to a "pending" page is a poor user experience. Prismø uses Laravel Reverb to push the subscription activation to the browser in real time. When Stripe fires the checkout.session.completed webhook, SubscriptionActivatedListener broadcasts a SubscriptionActivatedEvent over a private user channel. The browser receives this via Laravel Echo and updates the UI immediately:

// resources/js/exports/payment-processed.js
// Alpine component that listens for the broadcast event
// and shows the payment confirmed banner

The web-app security middleware group is intentionally stripped from the Stripe checkout routes. CSP and security headers block the inline scripts Stripe injects into its hosted checkout page, so these routes get a less restrictive security posture while still enforcing CSRF and session handling.

Payment issues

If Stripe fails to charge on renewal (expired card, insufficient funds), a global PaymentIssue banner component appears at the top of every page until the issue is resolved. Clicking "Update Billing" opens the Stripe Billing Portal directly.


Security Middleware Stack

All authenticated routes are served through a web-app middleware group that applies a full security header stack:

  • RemoveHeaders — strips the Server header from all responses
  • ContentTypeOptions — sets X-Content-Type-Options: nosniff
  • PermissionsPolicy — restricts camera, microphone, geolocation access
  • SetReferrerPolicystrict-origin-when-cross-origin
  • StrictTransportSecurity — enforces HTTPS with a 1-year max-age
  • CertificateTransparencyPolicyExpect-CT header
  • AddCspHeaders — Content Security Policy via Spatie's CSP package

Session data is encrypted (SESSION_ENCRYPT=true), sessions have a 180-day lifetime, and CSRF token validation covers all state-changing requests except the Stripe webhook endpoint (which uses its own HMAC signature verification).


Data Model

The schema is organised into five logical domains:

Users domain

  • users — GitHub ID, name, email, Stripe billing fields
  • users_settings — App Store Connect credentials (all encrypted), base country preference, per-user configuration
  • sessions — database-backed sessions

Apple domain

  • apps — imported App Store applications with cached metadata (icon URL, bundle ID, version, platforms, categories)
  • apps_products — pricing products attached to each app (subscription, IAP, or paid app), with the product's Apple ID and product type
  • apps_pricing — per-territory price records for each product, including current price, last synced timestamp

System domain

  • system_countries — reference data for all App Store territories: name, ISO codes, currency, PPP data (dollar_price from whichever source applies), is_zero_decimal flag, data source (Big Mac or World Bank)
  • system_exchange_rates — currency to USD rates, refreshed daily
  • system_price_points — Apple's official price point tiers per territory per currency, synced from App Store Connect

Billing domain

  • subscriptions, subscription_items — managed entirely by Laravel Cashier

Infrastructure domain

  • jobs, job_batches, failed_jobs — Laravel's standard queue tables
  • job_history — custom job execution history
  • scheduled_commands, scheduled_commands_runs — scheduled command execution tracking

All primary keys are UUIDs. Foreign keys use foreignUuid()->constrained() with cascade deletes where appropriate. Monetary values are stored as unsigned integers (cents) with a MoneyInCents cast in application code.


A Naming Change Worth Noting

During development, the concept now called a "product" was initially called a "strategy". The rename appears in the git history as a sequence of commits titled "renamed strategy to product". This is visible in several places: the apps_products table name (rather than apps_strategies), route names like apps.products, and the ProductTypeEnum. The change was cosmetic but pervasive. Any legacy references to "strategy" in the git history or early documentation refer to what is now called a product.


Testing

The test suite uses Pest v4 on PHPUnit 12. All tests run against an in-memory SQLite database that is refreshed before each test. The test environment uses synchronous queue execution, array cache and session drivers, and enforces a configuration cache guard to prevent accidental writes to a real database.

Test organisation by type:

tests/
  Arch/               # Architecture conformance tests (no debug calls in production, strict types enforced, etc.)
  Feature/
    Commands/         # Artisan command integration tests
    Livewire/         # Full Livewire component interaction tests
    Seeders/          # Database seeder tests
  Unit/
    Actions/          # Action class unit tests
    Services/         # Service class unit tests (PPP calculator, App Store Connect services)
    Models/           # Model attribute, cast, and relationship tests
    Enums/            # Enum method tests (labels, API endpoints, payload builders)
    Concerns/         # Trait unit tests (HasNotifications, HasPagination)
    Policies/         # Authorisation policy tests

The architecture tests include a stress test that verifies strict types are declared in every PHP file and that no debug helpers (dd(), dump(), ray()) are present in production code paths. These run as part of the standard suite.

Coverage reached 100% before submission. The test suite was built iteratively alongside the application code, with a dedicated pass to bring coverage to completion after the core feature work was done.

A representative Livewire component test illustrates the style. Pest's actingAs, Livewire::test, and fluent ->call() assertions are chained:

it('can add a new app', function (): void
{
    $user = User::factory()->create();

    actingAs($user)
        ->Livewire::test(Apps\Create::class)
        ->call('selectApp', $appId)
        ->assertDispatched('refresh-apps');

    expect($user->applications)->toHaveCount(1);
});

Tools and Conventions

PHPStan at level 9

Static analysis runs at the strictest level. Every model has comprehensive @property-read annotations for all columns and relationships. All method return types are declared. A phpstan.neon configuration excludes only the vendor directory and generated files.

Rector for automated refactoring

A rector.php configuration runs periodically to apply automated upgrades: deprecated API replacements, type-safe rewrites, and PHP 8.4 specific improvements. This keeps the codebase aligned with the language version without requiring manual hunting for outdated patterns.

Laravel Pint for code style

All PHP files are formatted with Pint using the project's pint.json configuration before each commit. The --dirty flag runs only on modified files, keeping the feedback loop fast.

Immutable Carbon throughout

All datetime handling uses CarbonImmutable. This prevents the common Carbon mutation bug where modifying a date returned from a model method unexpectedly changes the model's internal state. The cast is declared on every datetime column in every model.

Constructor property promotion

All constructor dependencies use PHP 8 constructor property promotion. No separate property declarations and constructor assignments. public function __construct(private readonly PriceRounderService $priceRounder) is the standard form throughout.


The app:setup Command

A custom app:setup Artisan command handles the full development environment bootstrap:

  1. Copies .env.example to .env if it does not exist
  2. Generates the application key
  3. Runs all migrations
  4. Seeds the database with system data (countries, initial exchange rates)
  5. Builds frontend assets via Vite
  6. Fetches live exchange rate and PPP data from the external APIs

Each step checks whether it has already been completed and skips accordingly. Running app:setup twice is safe. This makes onboarding a new development environment a single command rather than a checklist of steps.


Resources Page

The Resources page provides transparency into the reference data powering all PPP calculations. It has three tabs:

Exchange Rates

shows the current USD-based exchange rate for every currency in the system, with the last-updated timestamp. Developers can verify the exchange rates being used for their calculations.

Price Points

Apple's complete price point grid for the user's base country currency. Shows every valid price tier, formatted with the correct currency symbol. This is also where the base price dropdown on the pricing page is populated from.

Country Rates

the full country dataset showing the PPP data for every territory. Each row shows the country's dollar_price equivalent, which source supplied the data (Big Mac Index shown with a hamburger icon and blue badge, World Bank shown with a landmark icon and purple badge), and when the data was last updated. This table is searchable and sortable.

The Resources section has no write operations. It is read-only reference data to help developers understand and trust the underlying calculations before they push prices.


Deployment and Configuration

The application is configured entirely through environment variables. No production-specific PHP configuration files exist. Key configuration points:

  • FREE_TIER_MAX_APPS and FREE_TIER_MAX_PRODUCTS control the free tier limits without code changes
  • BASE_PRICING_COUNTRY defaults to US but can be changed if the platform operator has a different home market
  • JOB_HISTORY_PRUNE_MONTHS controls how long execution records are retained
  • Session lifetime is set to 180 days (SESSION_LIFETIME=259200) to keep developers logged in between working sessions

The stripe webhook endpoint (stripe/webhook) is excluded from CSRF token validation. It verifies incoming payloads using the STRIPE_WEBHOOK_SECRET signing secret instead.

All credentials passed through environment variables are accessed exclusively through the config() helper in application code. The env() function is never called outside of configuration files. This is enforced by the PHPStan analysis and a Rector rule that flags direct env() calls.


Key Challenges

Apple's inconsistent pricing APIs

Subscriptions, IAPs, and paid apps each have different endpoints, different payload structures, and different concepts of what a "price schedule" is. A subscription price schedule sets a price for a set of territories indefinitely. An IAP price schedule has a base territory concept. A paid app price schedule works differently again. The ProductTypeEnum with its appleConnectApiPushPricePayload() method consolidating this branching into a single enum was the cleanest solution found.

JWT token lifecycle

App Store Connect JWTs expire after 20 minutes. The initial approach regenerated a token on every HTTP request, which is correct for correctness but wastes a small amount of CPU time. Since each price push operation to all 175 territories happens in a single synchronous batch, a token generated at the start of the operation is valid throughout. The current approach generates once in generateToken() and reuses within the lifetime of the service instance.

Rounding to valid Apple price points

Apple's price points are not evenly spaced. They do not follow a simple "round to nearest whole number" rule. The valid price points for a given territory are fetched from Apple's API and stored locally so PriceRounderService can snap to the nearest actual valid value. A naive rounding algorithm would generate prices Apple's API refuses.

Real-time subscription confirmation without polling

The naive approach to Stripe webhook integration is to redirect to a "please wait" page and poll. Reverb removes the need for polling: the browser subscribes to a private user channel, and the webhook listener broadcasts the confirmation event directly. The result is a near-instantaneous confirmation banner without any client-side polling loop.

Credential security

The ES256 private key is a sensitive credential that must be available server-side for every App Store Connect API call but must never appear in logs, client responses, or the browser. Laravel's encrypted cast encrypts at write time and decrypts at read time, both using the application key. The key never exists in memory in decrypted form except in the brief window when an API call is being prepared.


Summary

Prismø is a focused tool that does one thing: calculates PPP-adjusted App Store prices and pushes them to Apple. The architecture reflects that focus. There are no feature flags, no multi-tenancy concerns, no content management layer, and no mobile API. The entire surface area is a handful of Livewire pages backed by a well-defined service and action layer.

The combination of Livewire for the UI, Action classes for business operations, and a typed, annotated Eloquent model layer produces code that is straightforward to read, test, and extend. PHPStan at level 9 and 100% test coverage provide confidence that the type contracts and business rules hold as the codebase evolves.

There have been no updates to this project.


Comments (0)

Optional. If given, is displayed publicly.

Optional. If given, is not displayed anywhere.

Required with 25 characters minimum

Comments are reviewed for spam and may require approval.

No comments yet. Be the first to share your thoughts!

Stay in the Loop

Occasional updates on new articles and projects. No spam guaranteed