Speeding Up Laravel Tests with tmpfs (MySQL in RAM)
If your Laravel test suite is painfully slow and does a lot of database work (multi-tenancy, migrations, seeding), the bottleneck is almost…
Creating custom UI components in Statamic addons with AJAX functionality is poorly documented, making it frustrating to implement. This guide shows you exactly how to build Vue components, handle AJAX requests, and integrate them seamlessly into the Statamic Control Panel.
Statamic addons can include Vue components that integrate with the Control Panel. Here's what you need:
resources/js/components/First, create vite.config.js in your addon root:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue2';
export default defineConfig({
plugins: [
laravel({
hotFile: '../../../public/vendor/internal-linker/hot',
buildDirectory: 'vendor/internal-linker',
input: [
'resources/js/cp.js',
],
refresh: true,
}),
vue(),
],
});
Add build scripts to your package.json:
{
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.3.1",
"vite": "^5.0",
"laravel-vite-plugin": "^1.0"
}
}
Here's a simple component that makes AJAX requests:
<template>
<div class="card p-4">
<h2 class="mb-4">My Custom Component</h2>
<div v-if="loading" class="loading loading-basic active">
<span>Loading...</span>
</div>
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{{ error }}
</div>
<div v-else>
<button @click="fetchData" class="btn-primary">
Fetch Data
</button>
<div v-if="data" class="mt-4">
<pre>{{ data }}</pre>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
apiUrl: String,
entryId: String
},
data() {
return {
loading: false,
error: null,
data: null
};
},
methods: {
async fetchData() {
this.loading = true;
this.error = null;
try {
const response = await this.$axios.get(this.apiUrl, {
params: { entry_id: this.entryId }
});
this.data = response.data;
} catch (error) {
// Handle session expiration
if (error.response?.status === 419) {
this.error = 'Your session has expired. Please refresh the page.';
} else {
this.error = error.response?.data?.message || 'An error occurred';
}
} finally {
this.loading = false;
}
}
}
};
</script>
Create resources/js/cp.js to register your components:
import MyComponent from './components/MyComponent.vue';
import AnotherComponent from './components/AnotherComponent.vue';
// Register when Statamic boots
Statamic.booting(() => {
// Register as a fieldtype
Statamic.component('my-component-fieldtype', MyComponent);
// Register as a general component
Statamic.component('another-component', AnotherComponent);
});
In your addon's routes/cp.php:
use Illuminate\Support\Facades\Route;
Route::prefix('internal-linker')->group(function () {
// GET endpoint for fetching data
Route::get('/data', [MyController::class, 'getData'])
->name('internal-linker.data');
// POST endpoint for saving data
Route::post('/save', [MyController::class, 'saveData'])
->name('internal-linker.save');
// More complex endpoint with validation
Route::post('/process', [MyController::class, 'process'])
->name('internal-linker.process');
});
Create controllers that handle your AJAX requests:
<?php
namespace YourVendor\YourAddon\Http\Controllers\CP;
use Statamic\Http\Controllers\CP\CpController;
use Illuminate\Http\Request;
class MyController extends CpController
{
public function getData(Request $request)
{
$entryId = $request->input('entry_id');
// Your business logic here
$data = $this->myService->processEntry($entryId);
return response()->json([
'success' => true,
'data' => $data
]);
}
public function saveData(Request $request)
{
$request->validate([
'entry_id' => 'required|string',
'content' => 'required|string',
]);
try {
$result = $this->myService->save(
$request->input('entry_id'),
$request->input('content')
);
return response()->json([
'success' => true,
'message' => 'Data saved successfully',
'data' => $result
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to save data'
], 500);
}
}
}
async saveData() {
try {
const response = await this.$axios.post('/cp/internal-linker/save', {
entry_id: this.entryId,
content: this.content
});
// Show success message
this.$toast.success(response.data.message);
// Emit event to parent
this.$emit('saved', response.data);
} catch (error) {
if (error.response?.status === 419) {
this.$toast.error('Session expired. Please refresh the page.');
} else {
this.$toast.error('Failed to save data');
}
}
}
In your service provider:
use Statamic\Facades\CP\Nav;
protected function bootAddon()
{
Nav::extend(function ($nav) {
$nav->content('My Addon')
->section('Tools')
->route('internal-linker.stats')
->icon('charts');
});
}
Create a Blade view for your CP page:
@extends('statamic::layout')
@section('title', 'My Custom Page')
@section('content')
<div class="flex items-center justify-between mb-6">
<h1>My Custom Page</h1>
</div>
<my-component
:api-url="'{{ cp_route('internal-linker.data') }}'"
:entry-id="'{{ $entry_id }}'"
></my-component>
@stop
@push('head')
@vite('resources/js/cp.js', 'vendor/internal-linker')
@endpush
<template>
<modal name="my-modal" :pivot-y="0.1" width="80%" height="auto">
<div class="modal-content p-4">
<header class="text-lg font-semibold mb-4">
{{ title }}
</header>
<div class="modal-body">
<!-- Your content here -->
</div>
<footer class="flex justify-end gap-2 mt-6">
<button @click="$modal.hide('my-modal')" class="btn">
Cancel
</button>
<button @click="save" class="btn-primary" :disabled="loading">
Save Changes
</button>
</footer>
</div>
</modal>
</template>
<script>
export default {
methods: {
show() {
this.$modal.show('my-modal');
},
hide() {
this.$modal.hide('my-modal');
},
async save() {
// Your save logic
this.hide();
}
}
};
</script>
For components that need to refresh data:
mounted() {
// Initial load
this.loadData();
// Refresh every 30 seconds
this.refreshInterval = setInterval(() => {
this.loadData();
}, 30000);
},
beforeDestroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
async loadData() {
// Only refresh if not currently loading
if (!this.loading) {
await this.fetchData();
}
}
}
Users need visual feedback. Always implement loading indicators:
data() {
return {
loading: false,
saving: false,
deleting: false
};
}
Create a mixin for consistent error handling:
// mixins/ErrorHandler.js
export default {
methods: {
handleError(error) {
if (error.response?.status === 419) {
this.$toast.error('Session expired. Please refresh the page.');
} else if (error.response?.status === 422) {
const errors = error.response.data.errors;
Object.keys(errors).forEach(field => {
this.$toast.error(errors[field][0]);
});
} else {
this.$toast.error(error.response?.data?.message || 'An error occurred');
}
}
}
};
Leverage Statamic's components for consistency:
<template>
<div>
<!-- Data table -->
<data-list
:rows="items"
:columns="columns"
:sort="false"
:search="true"
/>
<!-- Loading state -->
<loading-graphic v-if="loading" />
<!-- Empty state -->
<div v-if="!loading && items.length === 0" class="card p-6 text-center">
<p class="text-gray-500">No items found</p>
</div>
</div>
</template>
Don't hardcode URLs. Pass them from Blade:
<my-component
:routes='{
"fetch": "{{ cp_route('internal-linker.data') }}",
"save": "{{ cp_route('internal-linker.save') }}",
"delete": "{{ cp_route('internal-linker.delete') }}"
}'
/>
CSRF Token Issues: Statamic's axios instance handles this automatically, but make sure you're using this.$axios not importing axios directly.
Asset Loading: Always use the @vite directive with your package name to ensure assets load correctly.
Route Names: Prefix your routes to avoid conflicts with other addons.
State Management: For complex UIs, consider using Vuex (already included with Statamic).
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com