Payments, Widgets, and Profile
Flute CMS provides a payment system via Omnipay, a flexible widget system, and user profile integration capabilities.
Payment System
Important: Flute CMS automatically handles payments via Omnipay. Creating payments, handling callbacks, verification — all this happens automatically in the core. You only need to:
- Register the Omnipay driver in the provider
- Add the gateway via the admin panel
Examples below show optional custom logic if you need to extend standard behavior.
Minimal Integration
To add a new payment gateway, it is enough to:
<?php
// In module provider
protected function registerPaymentGateway(): void
{
events()->addDeferredListener(
\Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME,
function($event) {
$factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class);
$factory->register('mygateway', MyOmnipayGateway::class);
}
);
}After that, add the gateway in the admin panel (Settings → Payment Systems) and specify adapter = name of your Omnipay driver.
Everything else (creating payments, callbacks, status checking) is handled automatically by Flute core.
Architecture (for understanding)
The payment system is built on the following components:
| Component | Description |
|---|---|
PaymentGateway | Entity with gateway settings (adapter, enabled, settings) |
RegisterPaymentFactoriesEvent | Event for registering custom drivers |
PaymentDriverFactory | Factory for creating payment drivers |
GatewayInitializer | Initializes Omnipay instances from enabled gateways |
PaymentProcessor | Automatically handles payment scenarios and callbacks |
PaymentPromo | Promocode validation |
Registering Payment Gateway (Detailed)
To add a custom payment gateway, register it in the module provider:
<?php
namespace Flute\Modules\Shop\Providers;
use Flute\Core\Support\ModuleServiceProvider;
use Flute\Modules\Shop\Gateways\CustomGateway;
class ShopProvider extends ModuleServiceProvider
{
public function boot(\DI\Container $container): void
{
$this->registerPaymentGateway();
$this->bootstrapModule();
}
protected function registerPaymentGateway(): void
{
// Registration via event
events()->addDeferredListener(
\Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME,
function($event) {
$factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class);
// 'custom' — driver alias
// CustomGateway::class — class implementing driver
$factory->register('custom', CustomGateway::class);
}
);
}
}After registering the driver, add a record to the payment_gateways table via admin panel with adapter equal to the Omnipay gateway name.
Creating Custom Gateway
Create a gateway class inheriting from AbstractGateway and implementing PaymentDriverInterface:
<?php
namespace Flute\Modules\Shop\Gateways;
use Flute\Core\Modules\Payments\Contracts\PaymentDriverInterface;
use Omnipay\Common\AbstractGateway;
class CustomGateway extends AbstractGateway implements PaymentDriverInterface
{
/**
* Gateway name for display
*/
public function getName(): string
{
return 'Custom Payment Gateway';
}
/**
* Default parameters (configured in admin)
*/
public function getDefaultParameters(): array
{
return [
'apiKey' => '',
'secretKey' => '',
'testMode' => false,
];
}
/**
* Creating payment
*/
public function purchase(array $parameters = []): \Omnipay\Common\Message\RequestInterface
{
return $this->createRequest(
\Flute\Modules\Shop\Gateways\Message\PurchaseRequest::class,
$parameters
);
}
/**
* Completing payment (callback)
*/
public function completePurchase(array $parameters = []): \Omnipay\Common\Message\RequestInterface
{
return $this->createRequest(
\Flute\Modules\Shop\Gateways\Message\CompletePurchaseRequest::class,
$parameters
);
}
/**
* PaymentDriverInterface Implementation
*/
public function processPayment(array $data): array
{
$response = $this->purchase($data)->send();
return [
'success' => $response->isSuccessful(),
'transaction_id' => $response->getTransactionReference(),
'amount' => $data['amount'],
'currency' => $data['currency'],
];
}
public function verifyPayment(string $transactionId): array
{
$response = $this->completePurchase([
'transactionReference' => $transactionId
])->send();
return [
'verified' => $response->isSuccessful(),
'amount' => $response->getAmount(),
'currency' => $response->getCurrency(),
];
}
}Working with Payments
Creating Payment
<?php
namespace Flute\Modules\Shop\Services;
use Flute\Modules\Shop\Database\Entities\Order;
class PaymentService
{
/**
* Create payment for order
*/
public function createPayment(Order $order): array
{
// Payment data
$paymentData = [
'amount' => $order->total,
'currency' => config('shop.currency', 'RUB'),
'description' => "Order #{$order->id}",
'returnUrl' => route('shop.payment.success', ['order_id' => $order->id]),
'cancelUrl' => route('shop.payment.cancel', ['order_id' => $order->id]),
'notifyUrl' => route('shop.payment.notify', ['order_id' => $order->id]),
'metadata' => [
'order_id' => $order->id,
'user_id' => $order->user_id,
]
];
// Get available payment gateways
$gateways = payments()->getAllGateways();
if (empty($gateways)) {
throw new \Exception('No available payment gateways');
}
// Use first available gateway
$gateway = reset($gateways);
// Create payment
$response = $gateway->purchase($paymentData)->send();
if ($response->isSuccessful() || $response->isRedirect()) {
return [
'success' => true,
'redirect_url' => $response->getRedirectUrl(),
'transaction_id' => $response->getTransactionReference(),
];
}
return [
'success' => false,
'error' => $response->getMessage(),
];
}
}Processing Callback
<?php
public function processCallback(Order $order, array $callbackData): bool
{
$transactionId = $callbackData['transaction_id'] ?? null;
if (!$transactionId) {
logs('payments')->error('Missing transaction_id in callback');
return false;
}
// Get gateway and verify payment
$gateways = payments()->getAllGateways();
$gateway = reset($gateways);
$verification = $gateway->completePurchase([
'transactionReference' => $transactionId
])->send();
if ($verification->isSuccessful()) {
// Update order status
$order->status = 'paid';
$order->paid_at = new \DateTime();
$order->transaction_id = $transactionId;
$order->save();
// Send notification
$this->sendPaymentSuccessNotification($order);
return true;
}
logs('payments')->error('Payment verification failed', [
'order_id' => $order->id,
'transaction_id' => $transactionId,
'error' => $verification->getMessage()
]);
return false;
}Checkout Page
Full example of checkout page with payment gateway selection:
{{-- Resources/views/pages/checkout.blade.php --}}
@extends('shop::layouts.app')
@push('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h1>{{ __('shop.checkout') }}</h1>
</div>
<div class="card-body">
{{-- Order Summary --}}
<div class="order-summary mb-4">
<h3>{{ __('shop.order_summary') }}</h3>
<div class="order-items">
@foreach($order->items as $item)
<div class="order-item d-flex justify-content-between">
<span>{{ $item->product->name }} x {{ $item->quantity }}</span>
<span>{{ number_format($item->total, 2) }} {{ $order->currency }}</span>
</div>
@endforeach
</div>
<hr>
<div class="order-total d-flex justify-content-between">
<strong>{{ __('shop.total') }}</strong>
<strong>{{ number_format($order->total, 2) }} {{ $order->currency }}</strong>
</div>
</div>
{{-- Payment Form --}}
<div class="payment-form">
<h3>{{ __('shop.payment_method') }}</h3>
@if($gateways = payments()->getAllGateways())
<form action="{{ route('shop.payment.process') }}" method="POST">
@csrf
{{-- Payment Gateway Selection --}}
<div class="mb-3">
<label for="gateway" class="form-label">
{{ __('shop.select_gateway') }}
</label>
<select name="gateway" id="gateway" class="form-control" required>
@foreach($gateways as $key => $gateway)
<option value="{{ $key }}">{{ $gateway->getName() }}</option>
@endforeach
</select>
</div>
<input type="hidden" name="order_id" value="{{ $order->id }}">
<button type="submit" class="btn btn-primary btn-lg w-100">
{{ __('shop.pay_now') }}
</button>
</form>
@else
<div class="alert alert-warning">
{{ __('shop.no_gateways_available') }}
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
@endpushPayment Controller
<?php
namespace Flute\Modules\Shop\Http\Controllers;
use Flute\Core\Support\BaseController;
use Flute\Core\Router\Annotations\Route;
use Flute\Modules\Shop\Database\Entities\Order;
use Flute\Modules\Shop\Services\PaymentService;
class PaymentController extends BaseController
{
protected PaymentService $paymentService;
public function __construct(PaymentService $paymentService)
{
$this->paymentService = $paymentService;
}
#[Route('/shop/checkout/{orderId}', name: 'shop.checkout', methods: ['GET'])]
public function checkout(int $orderId)
{
$order = Order::findByPK($orderId);
if (!$order || $order->user_id !== user()->id) {
return $this->error(__('shop.order_not_found'), 404);
}
if ($order->status !== 'pending') {
return redirect(route('shop.order.show', ['id' => $order->id]));
}
return view('shop::pages.checkout', [
'order' => $order
]);
}
#[Route('/shop/payment/process', name: 'shop.payment.process', methods: ['POST'])]
public function process()
{
$orderId = request()->input('order_id');
$gatewayKey = request()->input('gateway');
$order = Order::findByPK($orderId);
if (!$order || $order->user_id !== user()->id) {
return $this->error(__('shop.order_not_found'), 404);
}
try {
$result = $this->paymentService->createPayment($order, $gatewayKey);
if ($result['success'] && isset($result['redirect_url'])) {
return redirect($result['redirect_url']);
}
return $this->error($result['error'] ?? __('shop.payment_error'));
} catch (\Exception $e) {
logs('payments')->error('Payment error: ' . $e->getMessage());
return $this->error(__('shop.payment_error'));
}
}
#[Route('/shop/payment/success', name: 'shop.payment.success', methods: ['GET'])]
public function success()
{
return view('shop::pages.payment-success');
}
#[Route('/shop/payment/cancel', name: 'shop.payment.cancel', methods: ['GET'])]
public function cancel()
{
return view('shop::pages.payment-cancel');
}
#[Route('/shop/payment/notify', name: 'shop.payment.notify', methods: ['POST'])]
public function notify()
{
// Webhook from payment system
$data = request()->all();
logs('payments')->info('Payment notification received', $data);
$orderId = $data['order_id'] ?? $data['metadata']['order_id'] ?? null;
if (!$orderId) {
return response()->json(['error' => 'Order ID not found'], 400);
}
$order = Order::findByPK($orderId);
if (!$order) {
return response()->json(['error' => 'Order not found'], 404);
}
$success = $this->paymentService->processCallback($order, $data);
return response()->json(['success' => $success]);
}
}Widget System
Widgets allow adding dynamic content to site pages with customizable parameters.
Creating a Widget
Widget implements WidgetInterface:
<?php
namespace Flute\Modules\Blog\Widgets;
use Flute\Core\Modules\Page\Widgets\Contracts\WidgetInterface;
use Flute\Modules\Blog\Database\Entities\Article;
class RecentArticlesWidget implements WidgetInterface
{
/**
* Widget name for admin
*/
public function getName(): string
{
return __('blog.widget.recent_articles');
}
/**
* Widget icon
*/
public function getIcon(): string
{
return '<x-icon path="newspaper" />';
}
/**
* Widget settings with types
*/
public function getSettings(): array
{
return [
'limit' => [
'type' => 'number',
'label' => __('blog.widget.limit'),
'default' => 5,
'min' => 1,
'max' => 20,
],
'show_excerpt' => [
'type' => 'boolean',
'label' => __('blog.widget.show_excerpt'),
'default' => true,
],
'show_date' => [
'type' => 'boolean',
'label' => __('blog.widget.show_date'),
'default' => true,
],
'category_filter' => [
'type' => 'select',
'label' => __('blog.widget.category'),
'options' => $this->getCategoryOptions(),
'default' => null,
]
];
}
/**
* Rendering widget
*/
public function render(array $settings): string|null
{
$limit = $settings['limit'] ?? 5;
$categoryId = $settings['category_filter'] ?? null;
// Get articles
$query = Article::query()
->where('status', 'published')
->orderBy('publishedAt', 'DESC')
->limit($limit);
if ($categoryId) {
$query->where('category.id', $categoryId);
}
$articles = $query->fetchAll();
if (empty($articles)) {
return null; // Do not display empty widget
}
return view('blog::widgets.recent-articles', [
'articles' => $articles,
'settings' => $settings,
])->render();
}
/**
* Does the widget have settings
*/
public function hasSettings(): bool
{
return true;
}
/**
* Default widget width (1-12)
*/
public function getDefaultWidth(): int
{
return 12;
}
/**
* Minimal width
*/
public function getMinWidth(): int
{
return 6;
}
/**
* Widget category for grouping
*/
public function getCategory(): string
{
return 'blog';
}
/**
* Settings validation
*/
public function validateSettings(array $input): true|array
{
$validator = validator();
$validated = $validator->validate($input, [
'limit' => 'required|integer|min:1|max:20',
'show_excerpt' => 'boolean',
'show_date' => 'boolean',
'category_filter' => 'nullable|integer',
]);
return $validated ? true : $validator->getErrors()->toArray();
}
/**
* Save settings
*/
public function saveSettings(array $input): array
{
return $input;
}
/**
* Settings form (optional, if custom is needed)
*/
public function renderSettingsForm(array $settings): string|bool
{
return view('blog::widgets.recent-articles-settings', [
'settings' => $settings
])->render();
}
/**
* Widget action buttons
*/
public function getButtons(): array
{
return [
[
'title' => __('blog.widget.refresh_cache'),
'action' => 'refresh_cache',
'icon' => '<x-icon path="sync" />',
]
];
}
/**
* Action handling
*/
public function handleAction(string $action, ?string $widgetId = null): array
{
if ($action === 'refresh_cache') {
cache()->delete("widget_recent_articles_{$widgetId}");
return ['success' => true, 'message' => __('blog.cache_cleared')];
}
return ['success' => false, 'message' => __('blog.unknown_action')];
}
protected function getCategoryOptions(): array
{
$categories = \Flute\Modules\Blog\Database\Entities\Category::findAll();
$options = ['' => __('blog.all_categories')];
foreach ($categories as $category) {
$options[$category->id] = $category->name;
}
return $options;
}
}Widget Template
{{-- Resources/views/widgets/recent-articles.blade.php --}}
<div class="widget widget-recent-articles">
<div class="widget-header">
<h3 class="widget-title">
<x-icon path="newspaper" />
{{ __('blog.recent_articles') }}
</h3>
</div>
<div class="widget-body">
<div class="articles-list">
@foreach($articles as $article)
<article class="article-item">
<h4 class="article-title">
<a href="{{ route('blog.show', ['slug' => $article->slug]) }}">
{{ $article->title }}
</a>
</h4>
@if($settings['show_date'] ?? true)
<time class="article-date">
{{ $article->publishedAt->format('d.m.Y') }}
</time>
@endif
@if(($settings['show_excerpt'] ?? true) && $article->excerpt)
<p class="article-excerpt">
{{ Str::limit($article->excerpt, 100) }}
</p>
@endif
@if($settings['show_author'] ?? false)
<div class="article-author">
<x-icon path="user" />
{{ $article->author->name }}
</div>
@endif
</article>
@endforeach
</div>
<div class="widget-footer">
<a href="{{ route('blog.index') }}" class="view-all">
{{ __('blog.view_all') }}
<x-icon path="arrow-right" />
</a>
</div>
</div>
</div>Widget Settings Form
Custom form for configuring the widget in admin panel:
{{-- Resources/views/widgets/recent-articles-settings.blade.php --}}
<div class="widget-settings">
{{-- Number of articles --}}
<div class="mb-3">
<label for="limit" class="form-label">{{ __('blog.widget.limit') }}</label>
<input type="number"
id="limit"
name="limit"
class="form-control"
value="{{ $settings['limit'] ?? 5 }}"
min="1"
max="20"
required>
<small class="form-text text-muted">
{{ __('blog.widget.limit_description') }}
</small>
</div>
{{-- Show excerpt --}}
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
id="show_excerpt"
name="show_excerpt"
value="1"
class="form-check-input"
{{ ($settings['show_excerpt'] ?? true) ? 'checked' : '' }}>
<label for="show_excerpt" class="form-check-label">
{{ __('blog.widget.show_excerpt') }}
</label>
</div>
</div>
{{-- Show date --}}
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
id="show_date"
name="show_date"
value="1"
class="form-check-input"
{{ ($settings['show_date'] ?? true) ? 'checked' : '' }}>
<label for="show_date" class="form-check-label">
{{ __('blog.widget.show_date') }}
</label>
</div>
</div>
{{-- Show author --}}
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
id="show_author"
name="show_author"
value="1"
class="form-check-input"
{{ ($settings['show_author'] ?? false) ? 'checked' : '' }}>
<label for="show_author" class="form-check-label">
{{ __('blog.widget.show_author') }}
</label>
</div>
</div>
{{-- Category filter --}}
<div class="mb-3">
<label for="category_filter" class="form-label">
{{ __('blog.widget.category_filter') }}
</label>
<select id="category_filter" name="category_filter" class="form-control">
<option value="">{{ __('blog.widget.all_categories') }}</option>
@php
$categories = \Flute\Modules\Blog\Database\Entities\Category::findAll();
@endphp
@foreach($categories as $category)
<option value="{{ $category->id }}"
{{ ($settings['category_filter'] ?? null) == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
</div>Registering Widget
Automatically
Widgets from the Widgets/ folder are automatically registered when bootstrapModule() is called:
- RecentArticlesWidget.php
- CategoriesWidget.php
Profile Integration
Adding Tab to Profile
You can add a tab to user profile via TemplateInitialized event:
<?php
namespace Flute\Modules\Blog\Providers;
use Flute\Core\Support\ModuleServiceProvider;
class BlogProvider extends ModuleServiceProvider
{
public function boot(\DI\Container $container): void
{
$this->bootstrapModule();
$this->registerProfileTab();
}
protected function registerProfileTab(): void
{
events()->addDeferredListener(
\Flute\Core\Template\Events\TemplateInitialized::NAME,
function($event) {
$tpl = $event->getTemplate();
// Add tab to profile navigation
$tpl->prependTemplateToSection('profile_tabs', 'blog::partials.profile-tab', [
'user' => user()
]);
// Add tab content
$tpl->prependTemplateToSection('profile_tab_content', 'blog::partials.profile-content', [
'user' => user()
]);
}
);
}
}Tab Template
{{-- Resources/views/partials/profile-tab.blade.php --}}
<li class="nav-item">
<a class="nav-link" id="blog-tab" data-bs-toggle="tab" href="#blog" role="tab">
<x-icon path="blog" />
{{ __('blog.my_articles') }}
@if($user->articles->count() > 0)
<span class="badge bg-primary">{{ $user->articles->count() }}</span>
@endif
</a>
</li>Tab Content Template
{{-- Resources/views/partials/profile-content.blade.php --}}
<div class="tab-pane fade" id="blog" role="tabpanel">
<div class="blog-profile-section">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>{{ __('blog.my_articles') }}</h3>
<a href="{{ route('blog.create') }}" class="btn btn-primary">
<x-icon path="plus" />
{{ __('blog.create_article') }}
</a>
</div>
@if($user->articles->count() > 0)
<div class="articles-list">
@foreach($user->articles as $article)
<div class="article-item card mb-3">
<div class="card-body">
<h5 class="card-title">
<a href="{{ route('blog.show', ['slug' => $article->slug]) }}">
{{ $article->title }}
</a>
</h5>
<div class="article-meta text-muted small">
<span>
<x-icon path="calendar" />
{{ $article->createdAt->format('d.m.Y') }}
</span>
<span>
<x-icon path="eye" />
{{ $article->views }}
</span>
<span>
@if($article->status === 'published')
<span class="badge bg-success">{{ __('blog.published') }}</span>
@else
<span class="badge bg-warning">{{ __('blog.draft') }}</span>
@endif
</span>
</div>
<div class="article-actions mt-2">
<a href="{{ route('blog.edit', ['id' => $article->id]) }}" class="btn btn-sm btn-outline-primary">
{{ __('blog.edit') }}
</a>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-5">
<x-icon path="blog" class="fa-3x text-muted mb-3" />
<h4>{{ __('blog.no_articles') }}</h4>
<p class="text-muted">{{ __('blog.create_first_article_description') }}</p>
</div>
@endif
</div>
</div>Adding Statistics to Profile
<?php
protected function registerProfileStats(): void
{
events()->addDeferredListener(
\Flute\Core\Template\Events\TemplateInitialized::NAME,
function($event) {
$tpl = $event->getTemplate();
$tpl->prependTemplateToSection('profile_stats', 'blog::partials.profile-stats', [
'user' => user()
]);
}
);
}{{-- Resources/views/partials/profile-stats.blade.php --}}
<div class="col-md-4 mb-4">
<div class="card stat-card">
<div class="card-body text-center">
<x-icon path="blog" class="fa-2x text-primary mb-2" />
<h3>{{ $user->articles->count() }}</h3>
<p class="text-muted mb-0">{{ __('blog.articles') }}</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card stat-card">
<div class="card-body text-center">
<x-icon path="eye" class="fa-2x text-info mb-2" />
<h3>{{ $user->articles->sum('views') }}</h3>
<p class="text-muted mb-0">{{ __('blog.total_views') }}</p>
</div>
</div>
</div>Best Practices
Payments
- Always validate payments on the server
- Use HTTPS for payment forms
- Log all transactions
- Test in sandbox before production
Widgets
- Cache widget data
- Optimize DB queries
- Provide reasonable default values
- Validate all settings
Profile
- Check data access permissions
- Use AJAX for dynamic updates
- Cache statistics