Skip to content

Custom Theme Development ​

Introduction ​

Custom themes allow you to build fully customised checkout experiences for UddoktaPay. Each theme consists of a configuration class and optional Blade views.

What you can customise:

  • Checkout page layout and design
  • Gateway payment pages (MFS, Bangla QR, Bank, Binance, etc.)
  • Success, pending, and cancel result pages
  • Payment link and liquid checkout pages
  • Colors, typography, and CSS/JS injection via the admin panel

Prerequisites ​

  • Laravel Blade templating (Documentation)
  • Basic PHP OOP
  • HTML, CSS, and JavaScript

Directory Structure ​

themes/
└── MyTheme/
    ├── Config.php
    └── views/
        ├── layouts/
        │   └── app.blade.php
        ├── checkout/
        │   ├── checkout.blade.php
        │   ├── success.blade.php
        │   ├── pending.blade.php
        │   ├── cancel.blade.php
        │   ├── paymentlink.blade.php
        │   ├── default-paymentlink.blade.php
        │   └── liquid.blade.php
        ├── gateways/
        │   ├── mfs.blade.php
        │   ├── banglaqr.blade.php
        │   ├── bank.blade.php
        │   └── binance.blade.php
        └── components/
            └── fields/
                ├── text.blade.php
                ├── select.blade.php
                ├── textarea.blade.php
                ├── checkbox.blade.php
                ├── radio.blade.php
                └── file.blade.php

public/
└── themes/
    └── mytheme/
        └── assets/
            ├── thumbnail.png
            ├── css/
            ├── js/
            └── images/

WARNING

Naming Convention

  • themes/ directory must be PascalCase (e.g., MyTheme)
  • public/themes/ directory must be lowercase (e.g., mytheme)
  • Config.php namespace must be Themes\\{ThemeName}\\Config

TIP

  • themes/MyTheme/ — only Config.php is required; all views are optional
  • public/themes/mytheme/ — all publicly accessible static files, served via theme_asset()
  • components/fields/ — only create the field type files you want to override; missing types fall back to the default theme automatically

Theme Configuration Class ​

Config.php is the only required file for a theme. It registers the theme and exposes its admin settings form.

Create themes/MyTheme/Config.php:

php
<?php

declare(strict_types=1);

namespace Themes\MyTheme;

use Revoltify\Support\Forms\Components\Field;
use Revoltify\Support\Forms\Form;
use Revoltify\Themeify\AbstractTheme;

final class Config extends AbstractTheme
{
    public function name(): string
    {
        return 'My Theme';
    }

    public function description(): string
    {
        return 'A custom theme for UddoktaPay';
    }

    public function version(): string
    {
        return '1.0.0';
    }

    public function author(): string
    {
        return 'Your Name';
    }

    public function authorUrl(): string
    {
        return 'https://yourwebsite.com';
    }

    /**
     * Path relative to public/themes/{themeslug}/ — shown as the thumbnail in the admin panel.
     */
    public function logo(): string
    {
        return 'assets/thumbnail.png';
    }

    public function form(): Form
    {
        return Form::make([
            Field::make('primary_color')
                ->color()
                ->label('Primary Color'),

            Field::make('button_text_color')
                ->color()
                ->label('Button Text Color'),

            Field::make('custom_css')
                ->codeEditor()
                ->language('css')
                ->label('Custom CSS')
                ->columnSpanFull(),

            Field::make('custom_js')
                ->codeEditor()
                ->language('javascript')
                ->label('Custom JS')
                ->columnSpanFull(),
        ]);
    }
}

Available Field Types ​

MethodDescription
->text()Single-line text input
->password()->revealable()Password with show/hide toggle
->number()Numeric input
->color()Color picker
->select([...])Dropdown
->toggle()->default(true)Toggle switch
->textarea(rows: 4)Multi-line textarea
->codeEditor()->language('css')Code editor (css / javascript)
->columnSpanFull()Span the full grid width
->required()Mark as required
->helperText('...')Help text below the field
->collapsible() / ->collapsed()Collapsible section
->visibleWhen('field', 'value')Conditional visibility

Loading Assets ​

Place all public files under public/themes/{themeslug}/. These files are served directly by the web server and are not processed through any bundler.

theme_asset() ​

Use the theme_asset() helper to generate URLs to your theme's public files:

blade
<link rel="stylesheet" href="{{ theme_asset('assets/css/app.css') }}" />
<script src="{{ theme_asset('assets/js/app.js') }}"></script>
<img src="{{ theme_asset('assets/images/logo.png') }}" />

Global Helper Functions ​

These helpers are available in all theme views:

HelperReturnsDescription
app_name()stringSite name from brand settings
app_logo()stringURL to the site logo
app_round_logo()stringURL to the round/icon logo
app_favicon()stringURL to the site favicon
theme_asset($path)stringURL to a file in public/themes/{themeslug}/

Template Views ​

Every view automatically receives the following shared variables:

VariableTypeDescription
$pageTitlestringPage <title>
$brandSettingsobjectBrand configuration
$themeSettingsobjectValues saved from your Config::form() fields

Brand settings:

blade
{{ $brandSettings->site_name }}
{{ $brandSettings->seo_description }}
{{ $brandSettings->seo_keywords }}
{{ $brandSettings->google_tag_manager_id }}
{{ $brandSettings->fb_og_image }}
{{ $brandSettings->support_number }}
{{ $brandSettings->messenger_link }}
{{ $brandSettings->whatsapp_number }}
{{ $brandSettings->email_address }}
{{ $brandSettings->telegram_link }}
{{ $brandSettings->support_link }}

Layout ​

A shared master layout at views/layouts/app.blade.php is recommended but not required — each view can be a fully standalone HTML page. Use any CSS framework or JS library you prefer.

Standard form submission:

When a form is submitted, UddoktaPay returns validation and system error messages via Laravel's $errors collection. You must include logic to display these alerts either in your master layout (layouts/app.blade.php) or directly in each view.

Here are two examples of how to render them.

Example 1: Using Toastr If your theme includes jQuery and Toastr, you can render notifications as toast alerts:

blade
@if (isset($errors) && $errors->any())
    <script type="module">
        @foreach ($errors->unique() as $error)
            toastr.error("{{ __($error) }}");
        @endforeach
    </script>
@endif

Example 2: Standard HTML Alerts You can write your own logic to render standard HTML <div> alerts instead:

blade
@if (isset($errors) && $errors->any())
    <div class="alert alert-error">
        <ul>
            @foreach ($errors->unique() as $error)
                <li>{{ __($error) }}</li>
            @endforeach
        </ul>
    </div>
@endif

NOTE

$errors is always available via Laravel's session, just check for it and render accordingly.

WARNING

All forms that POST to UddoktaPay must include @csrf. Omitting it will result in a 419 Page Expired error. This applies to every form in every view.

Available View Data ​

All data is passed as flat scalar variables — no Eloquent model instances are exposed to views.

Shared Gateway Page Variables ​

Every gateway page (mfs, banglaqr, bank, binance) receives this common set of variables in addition to its page-specific ones:

VariableTypeDescription
$pageTitlestringPage title
$gatewayNamestringGateway display name (e.g. bKash)
$gatewayLogostringFull URL to gateway logo
$gatewayDescriptionstring|nullOptional description
$gatewayHomeUrlstringBack to gateway selection URL
$formActionUrlstringForm POST URL
$gatewayCancelUrlstringCancel URL
$localestringCurrent app locale
$hasQRboolWhether a QR code (image or SVG) is available
$createdAtCarbonImmutableWhen the payment was created
$expiresAtCarbonImmutableWhen the payment window closes
$isExpiredboolWhether the payment window has already closed
$amountstringRaw amount
$amountFormattedstringFormatted amount with currency
$currencystringCurrency code
$hasAmountboolWhether an amount is present
$discountstringRaw discount
$discountFormattedstringFormatted discount
$hasDiscountboolWhether a discount applies
$feestringRaw fee
$feeFormattedstringFormatted fee
$hasFeeboolWhether a fee applies

When the page is backed by a full payment record, these are also available:

VariableTypeDescription
$customerobjectCustomer details (name, email, phone)
$productarrayProduct metadata attached to the payment
$percentChargestringPercent-based charge
$fixedChargestringFixed charge
$totalChargestringTotal charge
$percentDiscountstringPercent-based discount
$fixedDiscountstringFixed discount
$totalDiscountstringTotal discount

NOTE

$createdAt and $expiresAt are Carbon instances. Format them in Blade as needed:

blade
{{ $expiresAt->format('h:i A') }}
{{ $expiresAt->diffForHumans() }}
<span data-expires-at="{{ $expiresAt->toIso8601String() }}"></span>

The payment window duration is controlled by the gateway's payment_window config (minutes, default 15). Use $isExpired to render an expired state instead of the payment form.

Countdown timer example (works on any gateway page):

blade
@if ($isExpired)
    <p>This payment session has expired.</p>
    <a href="{{ $gatewayHomeUrl }}">← Choose another method</a>
@else
    <span id="countdown" data-expires-at="{{ $expiresAt->toIso8601String() }}"></span>

    <script>
        const el = document.getElementById('countdown');
        const expiresAt = new Date(el.dataset.expiresAt).getTime();

        const tick = () => {
            const diff = expiresAt - Date.now();

            if (diff <= 0) {
                window.location.reload();
                return;
            }

            const m = Math.floor(diff / 60000);
            const s = Math.floor((diff % 60000) / 1000);
            el.textContent = `${m}:${String(s).padStart(2, '0')}`;
        };

        tick();
        setInterval(tick, 1000);
    </script>
@endif

Checkout Page (checkout/checkout.blade.php) ​

VariableTypeDescription
$pageTitlestringPage title
$customerNamestringCustomer full name
$customerEmailstringCustomer email address
$customerPhonestringCustomer phone number
$amountstringRaw payment amount
$amountFormattedstringFormatted amount with currency (e.g. 100.00 USD)
$currencystringCurrency code
$cancelUrlstringCancel redirect URL
$gatewaysCollectionAll gateways
$mfsGatewaysCollectionMobile banking gateways
$globalGatewaysCollectionGlobal/international gateways
$bankGatewaysCollectionBank transfer gateways
$faqsCollectionFAQ entries
$hasMfsboolWhether MFS gateways exist
$hasGlobalboolWhether global gateways exist
$hasBankboolWhether bank gateways exist
$isSingleCategoryboolTrue when only one category has gateways

Gateway item:

blade
@foreach ($gateways as $gateway)
    {{ $gateway->code }}        {{-- e.g. 'bkash_personal' --}}
    {{ $gateway->displayName }} {{-- e.g. 'bKash Personal' --}}
    {{ $gateway->logo }}        {{-- Full URL to gateway logo --}}
    {{ $gateway->url }}         {{-- Checkout initiation URL --}}
@endforeach

FAQ item:

blade
@foreach ($faqs as $faq)
    {{ $faq->title }}
    {!! $faq->content !!}
@endforeach

Rendering example:

blade
<h1>{{ $amountFormatted }}</h1>
<p>{{ $customerName }} &mdash; {{ $customerEmail }}</p>

@if ($hasMfs)
    <h2>Mobile Banking</h2>
    @foreach ($mfsGateways as $gateway)
        <a href="{{ $gateway->url }}">
            <img src="{{ $gateway->logo }}" alt="{{ $gateway->displayName }}" />
            {{ $gateway->displayName }}
        </a>
    @endforeach
@endif

@if ($hasGlobal)
    <h2>International</h2>
    @foreach ($globalGateways as $gateway)
        <a href="{{ $gateway->url }}">
            <img src="{{ $gateway->logo }}" alt="{{ $gateway->displayName }}" />
            {{ $gateway->displayName }}
        </a>
    @endforeach
@endif

@if ($hasBank)
    <h2>Bank Transfer</h2>
    @foreach ($bankGateways as $gateway)
        <a href="{{ $gateway->url }}">
            <img src="{{ $gateway->logo }}" alt="{{ $gateway->displayName }}" />
            {{ $gateway->displayName }}
        </a>
    @endforeach
@endif

@if ($faqs->isNotEmpty())
    <section>
        @foreach ($faqs as $faq)
            <details>
                <summary>{{ $faq->title }}</summary>
                {!! $faq->content !!}
            </details>
        @endforeach
    </section>
@endif

MFS Gateway Page (gateways/mfs.blade.php) ​

Receives all shared gateway page variables plus:

VariableTypeDescription
$accountTypestringAccount type (e.g. Personal, Agent, Payment)
$accountNumberstringPayment account number
$qrCodestring|nullFull URL to QR code image (when configured)
$pendingPaymentEnabledboolWhether pending payment is allowed
$numberInputEnabledboolWhether sender phone input is shown
$gatewayConfigarray|nullGateway config array

NOTE

On MFS pages $amount, $discount, and $fee are ceiled to whole values (mobile wallets don't accept decimals), so $amountFormatted is safe to display directly as the amount to send.

Required form:

blade
<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <input type="text" name="transaction_id" required />

    @if ($numberInputEnabled)
        <input type="text" name="phone_number" />
    @endif

    <button type="submit">Verify</button>
</form>

TIP

Use $hasQR and $qrCode to optionally display a QR code image alongside the account number. Use $hasDiscount and $hasFee guards before rendering discount/fee values to avoid showing empty rows.

Rendering example:

blade
<img src="{{ $gatewayLogo }}" alt="{{ $gatewayName }}" />
<h2>{{ $gatewayName }} — {{ $accountType }}</h2>
<p>Send <strong>{{ $amountFormatted }}</strong> to:</p>
<p>{{ $accountNumber }}</p>

@if ($hasQR && $qrCode)
    <img src="{{ $qrCode }}" alt="QR Code" />
@endif

@if ($gatewayDescription)
    <p>{{ $gatewayDescription }}</p>
@endif

@if ($hasDiscount)
    <p>Discount: {{ $discountFormatted }}</p>
@endif

@if ($hasFee)
    <p>Fee: {{ $feeFormatted }}</p>
@endif

<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <input type="text" name="transaction_id" placeholder="Transaction ID" required />

    @if ($numberInputEnabled)
        <input type="text" name="phone_number" placeholder="Your phone number" />
    @endif

    <button type="submit">Verify Payment</button>
</form>

<a href="{{ $gatewayHomeUrl }}">← Back</a>
<a href="{{ $gatewayCancelUrl }}">Cancel</a>

Bangla QR Gateway Page (gateways/banglaqr.blade.php) ​

Receives all shared gateway page variables plus:

VariableTypeDescription
$qrSvgstring|nullInline SVG markup of the EMVCo QR code
$qrLogostringFull URL to the Bangla QR brand logo
$verifyUrlstringEndpoint to poll for payment status
$gatewayConfigarray|nullGateway config array

WARNING

$qrSvg contains raw SVG markup — render it with {!! !!}, not . The QR is time-boxed: always render the countdown from $expiresAt and handle the $isExpired state.

Rendering example:

blade
<img src="{{ $qrLogo }}" alt="Bangla QR" />
<h2>{{ $gatewayName }}</h2>

@if ($isExpired)
    <p>This QR code has expired.</p>
    <a href="{{ $gatewayHomeUrl }}">← Choose another method</a>
@else
    <div class="qr-wrapper">
        {!! $qrSvg !!}
    </div>

    <p>Scan with any Bangla QR supported banking app and pay <strong>{{ $amountFormatted }}</strong>.</p>
    <p>Expires at {{ $expiresAt->format('h:i A') }} (<span id="countdown" data-expires-at="{{ $expiresAt->toIso8601String() }}"></span>)</p>

    <script>
        // Poll payment status every 5 seconds
        setInterval(async () => {
            const response = await fetch(@json($verifyUrl));
            const data = await response.json();

            if (data.redirect_url) {
                window.location.href = data.redirect_url;
            }
        }, 5000);
    </script>
@endif

<a href="{{ $gatewayCancelUrl }}">Cancel</a>

Bank Gateway Page (gateways/bank.blade.php) ​

Receives all shared gateway page variables plus:

VariableTypeDescription
$parametersCollectionBank account details (key → value)
$bankFormFieldsCollectionDynamic form fields

Bank parameters:

blade
@foreach ($parameters as $key => $value)
    <span>{{ key_to_word($key) }}</span>
    <span>{{ $value }}</span>
@endforeach

Dynamic form fields:

See Dynamic Form Fields for details on styling and rendering these fields.

blade
<x-forms.bank :fields="$bankFormFields" />

Rendering example:

blade
<img src="{{ $gatewayLogo }}" alt="Bank Transfer" />

@if ($gatewayDescription)
    <div>{!! $gatewayDescription !!}</div>
@endif

<ul>
    @foreach ($parameters as $key => $value)
        <li>
            <span>{{ key_to_word($key) }}</span>
            <span>{{ $value }}</span>
        </li>
    @endforeach
</ul>

<p>Amount: {{ $amountFormatted }}</p>

@if ($hasDiscount)
    <p>Discount: {{ $discountFormatted }}</p>
@endif

@if ($hasFee)
    <p>Fee: {{ $feeFormatted }}</p>
@endif

<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <x-forms.bank :fields="$bankFormFields" />
    <button type="submit">Submit</button>
</form>

<a href="{{ $gatewayHomeUrl }}">← Back</a>
<a href="{{ $gatewayCancelUrl }}">Cancel</a>

Binance Gateway Page (gateways/binance.blade.php) ​

Receives all shared gateway page variables plus:

VariableTypeDescription
$gatewayConfigarrayGateway config array (e.g. binance_id, payment_timeout)

Required form:

blade
<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <input type="text" name="order_id" required />
    <button type="submit">Verify</button>
</form>

Rendering example:

blade
<img src="{{ $gatewayLogo }}" alt="Binance Pay" />
<p>Send <strong>{{ $amountFormatted }}</strong> to Binance ID:</p>
<p>{{ $gatewayConfig['binance_id'] }}</p>
<p>Complete this payment before {{ $expiresAt->format('h:i A') }}.</p>

@if ($hasDiscount)
    <p>Discount: {{ $discountFormatted }}</p>
@endif

@if ($hasFee)
    <p>Fee: {{ $feeFormatted }}</p>
@endif

<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <input type="text" name="order_id" placeholder="Binance Order ID" required />
    <button type="submit">Verify Payment</button>
</form>

<a href="{{ $gatewayHomeUrl }}">← Back</a>
<a href="{{ $gatewayCancelUrl }}">Cancel</a>

Success Page (checkout/success.blade.php) ​

VariableTypeDescription
$pageTitlestringPage title
$gatewayNamestringGateway used
$senderstring|nullSender phone number
$transactionIdstring|nullRaw transaction ID
$transactionIdFormattedstring|nullTruncated transaction ID (20 chars)
$amountstringRaw amount
$amountFormattedstringFormatted amount
$currencystringCurrency code
$statusstringRaw status
$statusFormattedstringHuman-readable status
$isCompletedboolPayment completed
$isPendingboolPayment pending
$isRefundedboolPayment refunded
$isFailedboolPayment failed
$isCanceledboolPayment cancelled

TIP

Use the $isCompleted, $isPending, $isFailed, etc. booleans to conditionally render different UI states on a single success page rather than duplicating markup across multiple views.

Rendering example:

blade
@if ($isCompleted)
    <p>Payment confirmed! Transaction: {{ $transactionIdFormatted }}</p>
@elseif ($isPending)
    <p>Your payment is pending review.</p>
@elseif ($isFailed)
    <p>Payment failed. Please try again.</p>
@elseif ($isRefunded)
    <p>Your payment has been refunded.</p>
@elseif ($isCanceled)
    <p>Payment was cancelled.</p>
@endif

<ul>
    <li>Gateway: {{ $gatewayName }}</li>
    <li>Amount: {{ $amountFormatted }}</li>
    <li>Status: {{ $statusFormatted }}</li>
    @if ($sender)
        <li>Phone: {{ $sender }}</li>
    @endif
</ul>

Pending Page (checkout/pending.blade.php) ​

Receives the same variables as the success page.

Rendering example:

blade
<h1>Payment Pending</h1>
<p>Your payment is under review. Please wait while we verify your transaction.</p>

<ul>
    <li>Gateway: {{ $gatewayName }}</li>
    <li>Amount: {{ $amountFormatted }}</li>
    @if ($transactionIdFormatted)
        <li>Transaction: {{ $transactionIdFormatted }}</li>
    @endif
    @if ($sender)
        <li>Phone: {{ $sender }}</li>
    @endif
</ul>

Cancel Page (checkout/cancel.blade.php) ​

VariableTypeDescription
$pageTitlestringPage title

Rendering example:

blade
<h1>Payment Cancelled</h1>
<p>Your payment was cancelled. No charges were made.</p>
VariableTypeDescription
$pageTitlestringPage title
$currencystringCurrency code
$productNamestringProduct name
$productDescriptionstringProduct description
$formActionUrlstringForm POST URL
$formFieldsCollectionDynamic custom fields

Dynamic form fields:

See Dynamic Form Fields for details on styling and rendering these fields.

blade
<x-forms.paymentlink :fields="$formFields" />

Rendering example:

blade
<h1>{{ $productName }}</h1>
<p>{{ $productDescription }}</p>
<p>Currency: {{ $currency }}</p>

<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <x-forms.paymentlink :fields="$formFields" />
    <button type="submit">Pay Now</button>
</form>
VariableTypeDescription
$pageTitlestringPage title
$currencystringCurrency code
$formActionUrlstringForm POST URL

Required form:

blade
<form method="POST" action="{{ $formActionUrl }}" enctype="multipart/form-data">
    @csrf
    <input type="hidden" name="currency" value="{{ $currency }}" />
    <input type="text" name="name" required />
    <input type="text" name="email" required />
    <input type="text" name="phone" required />
    <input type="text" name="amount" required />
    <input type="text" name="reference" />
    <button type="submit">Submit</button>
</form>

Liquid Checkout Page (checkout/liquid.blade.php) ​

VariableTypeDescription
$pageTitlestringPage title
$formActionUrlstringForm POST URL

Required form:

blade
<form method="POST" action="{{ $formActionUrl }}">
    @csrf
    <input type="text" name="name" required />
    <input type="text" name="email" required />
    <input type="text" name="phone" required />
    <input type="text" name="reseller_id" required />
    <input type="text" name="amount" required />
    <input type="text" name="reference" />
    <button type="submit">Submit</button>
</form>

Dynamic Form Fields ​

When building the Bank Gateway (gateways/bank.blade.php) or Payment Link (checkout/paymentlink.blade.php) pages, use the provided components to render dynamic fields configured by the merchant:

blade
{{-- Bank gateway form fields --}}
<x-forms.bank :fields="$bankFormFields" />

{{-- Payment link form fields --}}
<x-forms.paymentlink :fields="$formFields" />

These components iterate over the available fields and resolve each one by looking for components/fields/{type}.blade.php in your theme, where type matches $data->type (e.g., text, select, textarea, checkbox, radio, file). If a specific type view is not found in your theme, it falls back to the default theme automatically.

To customise the HTML markup or styling for a specific field type, create the corresponding view in your theme's components/fields/ directory. Each view receives a $data object containing:

blade
{{ $data->name }}        {{-- Label (e.g. "Account Number") --}}
{{ $data->key }}         {{-- Input name attribute (e.g. "account_number") --}}
{{ $data->is_required }} {{-- boolean --}}
{{ $data->options }}     {{-- array, for select/checkbox/radio options --}}
{{ $data->extensions }}  {{-- array, for file input allowed extensions --}}

NOTE

You only need to create the field type files you want to customise. Any type missing from your theme automatically falls back to the default implementation — you never need to create all six types.

Troubleshooting ​

Theme not appearing in the admin panel:

  • Verify themes/MyTheme/ is PascalCase and the namespace in Config.php is Themes\{ExactDirName}\Config
  • Ensure all required methods are implemented: name(), description(), version(), author(), form()

Thumbnail not showing:

  • Place the thumbnail at public/themes/{themeslug}/assets/thumbnail.png
  • Ensure logo() returns the correct relative path

Views not loading:

  • Confirm the theme is activated in Themes
  • Ensure view files are inside themes/MyTheme/views/

theme_asset() returning a 404:

  • Verify files exist under public/themes/{themeslug}/

Settings not available in views:

  • Always provide fallback values: $themeSettings->button_color ?? '#000'