Custom Admin Panel Development
Custom admin panel — solution when standard tools (Filament, AdminJS, Django Admin) don't cover business logic complexity or require extensive customization. Suitable for projects with specific workflows, non-standard interfaces, and high design/UX requirements for internal users.
When Custom Panel Needed
- Complex data visualizations difficult to implement in ready frameworks
- Non-standard access rights with granular control
- Integration with external systems (1C, CRM, telephony) in interface
- Specific workflows (multi-stage workflows, approvals)
- High branding and UX demands for internal users
Technology Stack
Backend: Laravel + REST API (Resource Controllers, API Resources, Policies) Frontend: React + TanStack Query + React Hook Form + Shadcn/ui Tables: TanStack Table v8 (virtualization, sorting, filtering server/client-side) State: Zustand or Jotai for global state Auth: Spatie Laravel Permission for roles and permissions
Access Architecture
React SPA → Laravel API → Eloquent → PostgreSQL
↓
Gates & Policies
↓
Response Resources
Each endpoint protected via Sanctum (token auth) and checks permissions via Gate:
// AdminOrderController
public function index(Request $request): JsonResponse
{
$this->authorize('viewAny', Order::class);
$orders = Order::query()
->with(['customer', 'items.product'])
->when($request->status, fn($q, $s) => $q->where('status', $s))
->when($request->search, fn($q, $s) => $q->where(function($q) use ($s) {
$q->where('id', $s)
->orWhereHas('customer', fn($q) => $q->where('email', 'like', "%{$s}%"));
}))
->orderBy($request->sort_by ?? 'created_at', $request->sort_dir ?? 'desc')
->paginate($request->per_page ?? 25);
return OrderResource::collection($orders)->response();
}
Server-Side Pagination and Filtering
TanStack Table supports server-side operations. Table state synced with URL via query params:
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
// Sync with URL
useEffect(() => {
const params = new URLSearchParams();
params.set('page', String(pagination.pageIndex + 1));
params.set('per_page', String(pagination.pageSize));
sorting.forEach(s => {
params.set('sort_by', s.id);
params.set('sort_dir', s.desc ? 'desc' : 'asc');
});
router.replace(`?${params.toString()}`);
}, [columnFilters, sorting, pagination]);
Inline Editing
Custom panels often require table editing without separate page:
const EditableCell = ({ row, column, table }) => {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(row.original[column.id]);
const save = async () => {
await updateMutation.mutateAsync({
id: row.original.id,
[column.id]: value
});
setIsEditing(false);
};
if (!isEditing) {
return <span onDoubleClick={() => setIsEditing(true)}>{value}</span>;
}
return (
<input value={value} onChange={e => setValue(e.target.value)}
onBlur={save} onKeyDown={e => e.key === 'Enter' && save()} autoFocus />
);
};
Bulk Operations
Mandatory for large data volumes:
const selectedIds = table.getSelectedRowModel().rows.map(r => r.original.id);
const handleBulkAction = async (action: string) => {
await bulkMutation.mutateAsync({ ids: selectedIds, action });
table.resetRowSelection();
};
Access Rights at UI Level
Buttons and sections display only to users with permissions:
const { can } = usePermissions();
return (
<DropdownMenu>
{can('orders.update') && <DropdownMenuItem onClick={editOrder}>Edit</DropdownMenuItem>}
{can('orders.delete') && <DropdownMenuItem onClick={deleteOrder} className="text-red-500">Delete</DropdownMenuItem>}
</DropdownMenu>
);
Audit Log
All admin panel actions logged:
OrderAuditLog::create([
'admin_id' => auth()->id(),
'order_id' => $order->id,
'action' => 'status_changed',
'old_value' => $order->getOriginal('status'),
'new_value' => $order->status,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent()
]);
Real-time Updates
When other user updates data — notification via WebSocket:
useEffect(() => {
const channel = Echo.private('admin').listen('OrderUpdated', (event) => {
queryClient.invalidateQueries(['orders']);
toast.info(`Order #${event.orderId} updated`);
});
return () => channel.stopListening('OrderUpdated');
}, []);
Development timeline: 8–16 weeks depending on entity count, permission complexity, and custom logic volume.







