All lessons
E-commerceIntermediate11 min readIn my stack

Admin panels and CMS

What a real back office needs: roles, CRUD, media, SEO, reports, exports and audit logs.

  • Roles
  • CRUD
  • Media library
  • Reports
  • Exports
Step 1/5
An editor writes a new page in the admin panel. It's saved as a draft — the live site still shows the old banner.

In 30 seconds

The admin panel is the product the business uses every day: managing products, orders, customers, content and promotions. It needs fine-grained roles, fast lists with filters, a media library, SEO fields, reports and exports, and an audit log that answers "who changed this?". Building it from configuration instead of hand-writing every screen keeps it consistent and cheap to extend.

Why I use it

  • I use config-driven admin panels because I maintain an in-house React admin system with configurable CRUD resources, a media manager and a design system, reused across many client projects.
  • Customers see the storefront, but staff live in the admin. A slow or confusing admin costs the business hours every day.
  • Most admin screens are the same pattern — list, filter, create, edit, delete — so generating them from config frees time for the screens that are truly special.
  • Roles, audit logs and exports are what make a system trustworthy for a growing team.

How it works

01What every admin needs

Start with the users of the admin, not the database tables. An operator handling orders needs a fast order list with status filters and real-time updates. A content manager needs pages, a blog, media and SEO. A marketer needs promotions, segments and campaigns. A manager needs reports. Behind all of them sit the same building blocks: authentication (ideally with a second factor or one-time codes for admins), roles and permissions, CRUD screens (create, read, update, delete) with search, filters, sorting and pagination, bulk actions, a media library, settings, reports with exports, and an audit log. Notifications — web push or in-app — tell staff about new orders or failed syncs.

  • Lists: server-side pagination, filters in the URL so views can be shared.
  • Forms: validation on the server, clear errors, unsaved-changes warning.
  • Bulk actions with confirmation and a summary of what changed.

02Roles, permissions and audit logs

A role is a named bundle of permissions ("order operator", "content editor"); a permission is a single action on a resource, such as orders.view or products.update. Check permissions on the server for every request — hiding a button in the UI is convenience, not security. Keep permission names consistent so the admin menu, routes and buttons can all read the same list. For sensitive actions, such as refunds, manual balance changes or role changes, add extra friction: a reason field, a confirmation, maybe a second approver. An audit log records who did what, when, to which record, and the before/after values. When something goes wrong in production, the audit log turns guessing into a two-minute lookup.

php
// Laravel: one permission check per action, enforced on the server
Route::middleware(['auth:admin', 'can:orders.refund'])
    ->post('/admin/orders/{order}/refund', RefundOrderController::class);
 
// Audit every sensitive change with before/after values
AuditLog::create([
    'admin_id'   => $request->user()->id,
    'action'     => 'order.refunded',
    'subject'    => $order->getMorphClass().':'.$order->id,
    'before'     => ['status' => $before],
    'after'      => ['status' => $order->status, 'amount' => $amount],
    'reason'     => $request->string('reason'),
    'ip'         => $request->ip(),
]);

03Config-driven CRUD

Most admin screens differ only in fields and columns. Instead of writing each list and form by hand, describe a resource in configuration: its API endpoint, list columns, filters, form fields with types (text, number, select, rich text, image, slug, category tree) and permissions. A generic engine renders the list and form from that description, so a new resource takes minutes, and every screen gets the same pagination, validation display and bulk actions. On the backend, a generic resource controller can serve standard CRUD while custom endpoints handle special logic. Keep an escape hatch: some screens — an order detail page, a promotion builder, a dashboard — deserve custom components, and the config system should let you mount them.

ts
export const brandsResource = {
  name: "brands",
  endpoint: "/admin/brands",
  permission: "brands",
  list: {
    columns: ["logo", "name", "productsCount", "isActive"],
    filters: [{ field: "isActive", type: "boolean" }],
    search: ["name"],
  },
  form: [
    { name: "name", type: "text", required: true, translatable: true },
    { name: "slug", type: "slug", from: "name" },
    { name: "logo", type: "media", accept: "image/*" },
    { name: "description", type: "richText", translatable: true },
    { name: "seo", type: "seo" },
  ],
} as const;

04Media library and SEO fields

A media library stores uploads once and lets editors reuse them: folders, search, crop, replace (keeping links working), alt text and usage tracking. Validate uploads by content, not just the file extension, limit size and dimensions, strip metadata, and generate resized variants such as WebP thumbnails in a background job. Store files in object storage (S3-compatible) behind a CDN rather than the app server's disk once the site grows. SEO fields belong on every public model: slug, meta title, meta description, social image, canonical URL and a noindex switch, with sensible defaults generated from the content. The admin should also manage redirects, because renamed slugs otherwise create broken links, and the site should generate its sitemap from the same data.

05Reports and exports

Reports answer business questions: sales by day and channel, best products, new versus returning customers, promotion cost, average delivery time. Build them on queries designed for reporting — aggregated tables, read replicas or nightly summaries — so a heavy report never slows down checkout. Always show the date range and time zone, and make sure numbers match the order list (the same definition of "paid" everywhere). Exports to Excel or CSV are essential, since finance and marketing live in spreadsheets. Generate large exports in a queued job, stream rows instead of loading everything into memory, and send a download link when ready. Exports contain personal data, so protect them with permissions, short-lived links and an audit entry.

06Built-in CMS or headless CMS?

A CMS (content management system) lets non-developers edit pages and content. A built-in CMS lives inside your own admin and database: pages, blog, FAQ, banners and blocks next to products and orders. It is ideal when content is tightly linked to commerce data, and it costs nothing extra. A headless CMS (Contentful, Sanity, Strapi, Payload) is a separate service that stores content and serves it over an API; it shines for editorial teams, rich block-based layouts, previews and workflows. A hybrid is common: structured business data in your own backend, editorial content in a headless CMS. Whatever you choose, model content as typed blocks rather than one giant HTML field, so the frontend controls design and content stays portable.

Use it when

  • Any product where staff manage data daily: shops, ordering platforms, CRMs, booking systems.
  • Several projects need similar admin screens, making a reusable config-driven system worth building.
  • Teams with different responsibilities need different access levels and accountability.

Skip it when

  • A static marketing site with rare edits — a headless CMS or even Markdown files is simpler.
  • When a ready admin framework fits your stack well; do not rebuild Filament or Django admin for a standard case.
  • Avoid generic CRUD for complex workflows such as order handling; they need purpose-built screens.

Alternatives

  • Filament / Laravel Nova

    Admin panel frameworks for Laravel; very fast for CRUD, with resources, forms, tables and permissions.

  • React Admin / Refine

    React frameworks that generate admin UIs from data providers; good for custom backends.

  • Headless CMS (Contentful, Sanity, Strapi, Payload)

    Best for editorial content, previews and workflows; adds another service and cost.

  • Retool / internal tool builders

    Quick internal dashboards over databases and APIs; less suitable for customer-facing content.

Best practices

  1. Enforce permissions on the server for every endpoint; the UI only mirrors them.
  2. Use a second factor or one-time codes for admin login.
  3. Log sensitive actions with before/after values, author and reason.
  4. Generate standard CRUD from configuration and keep custom screens for complex workflows.
  5. Validate uploads by content and process images in background jobs.
  6. Give every public model SEO fields and manage redirects in the admin.
  7. Run heavy reports and exports in queues, away from the checkout database load.

Common mistakes

  • One "admin" role for everyone, so a content editor can issue refunds.
  • Hiding buttons in the UI but leaving the API unprotected.
  • Loading entire tables into the browser instead of server-side pagination.
  • Exports built in a web request that time out or exhaust memory.
  • Storing content as raw HTML blobs that break when the design changes.

Deep dive

07Authorisation beyond roles: scopes and policies

Role-based access control (RBAC) answers "may this person refund orders?" but not "may they refund orders of this branch?". Real back offices need both. Combine permissions with scopes: a branch manager has orders.refund limited to their branches, a supplier user sees only their products. Implement the check in one place per resource — a policy class — that takes the user, the action and the record, and apply the same scope to list queries so people never even see rows they cannot touch. This is a step towards attribute-based access control (ABAC), where rules use attributes of the user and the record. Cache each user's permissions per request, invalidate the cache when roles change, and write tests for every sensitive policy, because authorisation bugs are silent until they leak data.

php
// Laravel policy: permission plus scope in one place
class OrderPolicy
{
    public function refund(Admin $admin, Order $order): bool
    {
        return $admin->can('orders.refund')
            && $admin->branches()->whereKey($order->branch_id)->exists();
    }
}
 
// The same scope applied to list queries
Order::query()
    ->whereIn('branch_id', $admin->branches()->pluck('branches.id'))
    ->latest()
    ->paginate(50);

08Audit logs that hold up in an incident

An audit log is only useful if people trust it. Write entries at the service layer, so changes from screens, API calls, imports and console commands are all recorded. Record a diff of changed fields rather than whole rows, and mask secrets and sensitive personal data. Make the table append-only: the application's database user may insert but not update or delete, and retention cleanup runs as a separate privileged job. Add a request ID to each entry, so one admin action that touches ten records can be traced as one event. For high-value actions, also log reads — who exported the customer list, who viewed a full card-holder address. Keep audit data long enough for disputes and regulations, and make it searchable by record, by person and by time range.

sql
-- Append-only for the application user
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
GRANT INSERT, SELECT ON audit_logs TO app_user;
 
CREATE INDEX audit_logs_subject_idx ON audit_logs (subject_type, subject_id, created_at DESC);
CREATE INDEX audit_logs_actor_idx   ON audit_logs (admin_id, created_at DESC);

09Admin performance with large tables

Admin lists often become the slowest part of a system: an order table with millions of rows, a dozen filters, and a total count on every page. Index for the filters people actually use, usually composite indexes that start with status or branch and end with created date. Exact total counts on huge filtered tables are expensive; show "many results" or an estimate beyond a threshold, or count asynchronously. Offset pagination degrades on deep pages because the database still walks skipped rows; keyset (cursor) pagination stays fast. Avoid N+1 queries by eager-loading relations shown in columns. Search across many text columns belongs in a search engine or a full-text index, not chains of LIKE. Finally, put heavy dashboards on a read replica or summary tables, so a manager refreshing reports never slows down checkout.

  • Log slow admin queries and review them monthly.
  • Prefer cursor pagination for exports and infinite scroll.
  • Eager-load relations used in list columns.

10Securing the admin surface

The admin is the most powerful door into the business, so it deserves the strongest locks. Require a second factor for every admin account. Keep admin sessions short with an idle timeout, and re-authenticate before critical actions such as changing roles or payout details. Consider serving the admin on a separate subdomain, with stricter cookies and, where practical, an IP allowlist or VPN. Protect against CSRF on cookie-based sessions, and against stored XSS from rich-text fields by sanitising HTML on the server with an allowlist of tags. Uploaded files must never be executable: store them outside the web root or in object storage, and serve user-uploaded SVG carefully, because SVG can contain scripts. Monitor admin logins and alert on new devices or impossible travel.

Real-world scenarios

01

Who changed the price?

Problem

A best-selling product was sold for a week at half price. Three people have admin access and nobody remembers editing it, and there is no audit log.

How to solve it

For this incident, check whatever exists: web server logs for POST requests to the product endpoint, the database's updated_at, ERP sync logs and price import history. Then prevent the next one: add an audit log at the service layer recording actor, action, before and after values, request ID and source (screen, import or sync). Split the generic admin role into real roles so only the pricing team can update prices, and require a reason field for price changes above a threshold. Add a daily report of the largest price changes, and a sanity rule that holds changes of more than 30% for approval.

02

The order list takes 12 seconds to load

Problem

Operators complain that the orders screen is unusable at peak time. The table has three million rows and the list loads with a status filter, a search box and a total count.

How to solve it

Measure first: enable query logging or EXPLAIN ANALYZE on the list query. Typical fixes, in order: add a composite index matching the default filter and sort, such as (status, created_at DESC); remove the exact count or cap it at a threshold; eager-load customer and branch relations to remove N+1 queries; replace LIKE search on several columns with a dedicated search field or full-text index; switch to keyset pagination. Default the list to the last 30 days, which matches what operators need. Re-measure after each change, and add the query to a slow-query alert.

03

Marketing wants landing pages without developers

Problem

Every campaign landing page requires a developer to build and deploy it, which takes a week. Marketing wants to publish pages themselves, but the design team fears broken layouts.

How to solve it

Build a block-based page builder: developers create a library of typed blocks — hero, product grid, banner, FAQ, text with image — each with a schema of fields and validated limits, rendered by components that follow the design system. Editors compose pages from blocks, reorder them, schedule publication and preview before going live. Product grids reference real products or categories by ID, so prices and stock stay live. Version every page so editors can roll back. This keeps the design consistent, since editors choose content, not CSS, and new block types can be added when marketing outgrows the library.

Cheat sheet

Permission middleware on a route (Laravel)
Route::post('/admin/orders/{order}/refund', RefundOrder::class)->middleware('can:refund,order');
Super-admin bypass in one place
Gate::before(fn (Admin $a) => $a->hasRole('super-admin') ? true : null);
Validate image uploads
'logo' => ['required', File::image()->max(5 * 1024)->dimensions(Rule::dimensions()->maxWidth(4000))]
Short-lived private download link
Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(15));
Stream a large export
Order::query()->where(...)->lazyById(1000)->each(fn ($o) => $writer->addRow($o->toExportRow()));
Keyset pagination
Order::orderByDesc('id')->cursorPaginate(50);
Find slow list queries (PostgreSQL)
EXPLAIN (ANALYZE, BUFFERS) SELECT ... FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 50;
Append-only audit table
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;

In my real projects

  • I maintain an in-house React admin system as packages: config-driven CRUD resources, a media manager, around 20 design-system components and CKEditor, media, slug and tree fields, with a Laravel counterpart offering OTP admin login, roles, a generic resource controller and a media library.
  • On a restaurant-chain platform the admin has 12 roles, admin web push, a media library with folders, crop and replace, per-model SEO and sitemap, about 30 reporting controllers and about 40 Excel export endpoints.
  • On an agency website I built a hybrid CMS: structured data in a Laravel admin and editorial, block-based content in a headless CMS; that hand-built admin became the ancestor of the reusable admin packages.

Questions & answers

Q1How do you design roles and permissions for an admin?

I define permissions as resource-action pairs, such as orders.view, orders.refund or products.update, and group them into roles that match real jobs: operator, content editor, marketer, manager. The server checks a permission on every route; the frontend reads the same list to show menus and buttons. Super-admin is reserved for a few people. Sensitive actions get extra controls like a required reason, audit logging and sometimes a second approver. Custom roles let the business adapt without code changes.

Q2What is a config-driven admin and when is it worth it?

Instead of writing each list and form by hand, you describe a resource — endpoint, columns, filters, fields and permissions — and a generic engine renders the screens. It is worth it when you have many similar resources or several projects, because each new resource takes minutes and behaves consistently. It is not a replacement for everything: complex workflows like order processing or a promotion builder still need custom screens, so the system must support mounting custom components.

Q3Headless CMS or built-in CMS?

A built-in CMS keeps content in your own database next to products and orders, which is simple, free and ideal when pages mix commerce data. A headless CMS is a separate service with a strong editorial experience, previews, localisation and workflows, delivered over an API. I choose built-in for shops where content is mostly banners, pages and blog, and headless when a marketing team publishes rich block-based content often. A hybrid of both is common and works well.

Q4Why do you need an audit log?

Because in a team, "who changed this price?" or "who refunded that order?" will be asked, often during an incident. An audit log records the author, action, target record, before and after values, time and IP. It supports accountability, helps support teams answer customers, deters misuse, and is often required for compliance. It should be append-only for admins, searchable by record, and kept for a defined period. Logging at the service layer catches changes from every screen and API.

Q5How do you handle large exports?

Never in the web request. The admin clicks export, the server queues a job with the filters, and the job streams rows from the database in chunks into a CSV or Excel file, keeping memory flat. When done, it stores the file privately and notifies the user with a short-lived download link. Exports contain personal and financial data, so they require a permission, are logged in the audit trail, and files are deleted after a few days.

Senior-level questions

S1Build a custom admin, use a framework like Filament, or a low-code tool?

It depends on how central the admin is and how many projects share it. Low-code tools are fastest for internal dashboards over existing data, but they struggle with complex workflows, custom permissions and branding, and add per-seat costs. Frameworks such as Filament or Nova give excellent CRUD speed inside one stack, with the trade-off of following their conventions and upgrade cycles. A custom, config-driven admin costs the most upfront but pays back when many projects need the same patterns, when the admin must work against several backends, or when the UX is a product feature, for example for restaurant operators. I would pick the framework for a single project and a custom system only when reuse across projects is proven.

S2How do you design an audit system that does not slow down writes or explode in size?

Write audit entries in the same transaction for critical financial actions, because losing them is unacceptable, but keep them small: a diff of changed fields, not whole rows. For high-volume, low-risk changes, emit audit events to a queue or use database change data capture, accepting a small delay. Partition the audit table by month so queries stay fast and retention is a cheap drop of old partitions, possibly after archiving them to object storage. Index by subject and by actor, which covers most questions. Exclude noisy fields such as updated_at or view counters. The trade-off is completeness versus cost, so classify actions: financial and permission changes are always logged synchronously, everything else on a best-effort pipeline.

S3How would you add multi-tenancy to an existing admin?

First decide the isolation model. Row-level tenancy — a tenant_id column on every table — is cheapest and easiest to operate, but one forgotten filter leaks data, so enforce it centrally with a global query scope, or with PostgreSQL row-level security as a second line of defence. Schema-per-tenant or database-per-tenant gives stronger isolation and simpler per-tenant backups, at the cost of migrations across many schemas and harder cross-tenant reports. In the admin, the tenant comes from the authenticated user, never from a request parameter; super-admins switch tenant explicitly, and the switch is audited. Media, caches, queues and search indexes need tenant prefixes too. I add automated tests that try to read another tenant's records through every endpoint.

Glossary

CRUD
Create, read, update, delete: the four basic operations most admin screens provide.
RBAC
Role-based access control: permissions granted to roles, and roles assigned to users.
ABAC
Attribute-based access control: rules that also use attributes of the user and record, such as branch.
Policy
A class or function that decides whether a user may perform an action on a specific record.
Audit log
An append-only record of who did what, when, to which record, with before and after values.
Keyset pagination
Paging by the last seen key instead of an offset, so deep pages stay fast.
Headless CMS
A content service that stores content and delivers it over an API, without its own front end.
Content block
A typed, reusable piece of page content with a defined schema, rendered by a component.
Stored XSS
Malicious script saved in content, for example rich text, that runs in other users' browsers.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.