mrpowerup/filament-sql-field
A Filament form field that adds a SQL query editor with syntax highlighting and validation, letting admins write and run raw SQL directly inside your admin panel. Note: filenames and code strings are omitted from this summary.
1. Confirm your Filament major before you install. Release 2.0.2 is the line that tracks Filament 5. If you are on Filament 3 or 4, check which release line the package still supports — a 1.x → 2.x major bump usually drops older majors. Pin the exact version you validate, not a floating constraint:
composer require mrpowerup/filament-sql-field:2.0.2
2. Look at three things first, in this order:
vendor/mrpowerup/filament-sql-field/resources/ to see how the CodeMirror 5 assets are registered (published vendor assets vs. Vite) — this determines whether it survives your CSP and deploy pipeline.3. Pick the smallest real use case. The canonical first use is a SavedQuery resource: a name, a description, and a SQL body. That gives you persistence and the editor UI without touching execution, which is the part you must build anyway.
php artisan make:model SavedQuery -mf
Schema::create('saved_queries', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('dialect')->default('pgsql');
$table->longText('sql');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
});
4. Drop the field into the form schema. (Adjust the namespace to whatever the README specifies.)
use MrPowerUp82\FilamentSqlField\Forms\Components\SqlField; // verify namespace
public static function form(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')->required()->maxLength(255),
Select::make('dialect')
->options(['pgsql' => 'PostgreSQL', 'mysql' => 'MySQL'])
->required(),
SqlField::make('sql')
->label('SQL')
->required()
->columnSpanFull(),
]);
}
Open the create form in the browser. If the editor renders with syntax highlighting and light/dark matching your panel, asset registration works in your environment, and you can move on to the security layer. If it renders as a plain textarea, stop and fix assets before writing any feature code — that is the one integration failure mode that wastes the most time later, because it tends to reappear inside modals and repeaters.
Keep the field dumb; put all intelligence in a query service. The single most important pattern: treat SqlField as a view concern and own an editor-agnostic QueryService that validates, authorizes, executes, limits, and logs. Every other pattern here composes with it, and it makes swapping editors a one-file change.
final class QueryService
{
public function run(string $sql, User $actor, string $dialect): QueryResult
{
$this->guard->assertAllowed($sql, $actor); // single-statement, allow-list
$bounded = $this->limit->apply($sql, max: 1_000); // inject LIMIT if absent
$start = microtime(true);
try {
$rows = DB::connection('sql_readonly')
->statement("SET statement_timeout = '5s'"); // or per-dialect equivalent
$rows = DB::connection('sql_readonly')->select($bounded);
$this->audit->log($actor, $sql, 'ok', ms: ..., rows: count($rows));
return QueryResult::ok($rows);
} catch (QueryException $e) {
$this->audit->log($actor, $sql, 'error', error: $e->getMessage());
return QueryResult::failed($e->getMessage());
}
}
}
Console page pattern. A custom Filament page with the field on top, a Run action, and a results table below. Keep $sql in a public Livewire property but do not bind it live (->live()) on the SQL field — every keystroke round-tripping through Livewire is a poor experience for a 60-line query, and it exercises the editor re-render path unnecessarily. Run on explicit action.
class QueryConsole extends Page
{
public ?string $sql = '';
public array $rows = [];
public ?string $error = null;
public function run(): void
{
$result = app(QueryService::class)->run($this->sql, auth()->user(), 'pgsql');
$this->rows = $result->rows();
$this->error = $result->error();
}
}
Authoring vs. execution split. In a SavedQuery resource, the form holds the field; a separate table action or page action runs it. This keeps "who may write SQL" (->visible() on the field, or the whole resource) separate from "who may execute SQL" (a policy on the run action). Those are different permissions and should not be collapsed.
SqlField::make('sql')
->visible(fn () => auth()->user()->can('author-sql'))
->disabled(fn (?SavedQuery $record) => $record?->is_production_locked ?? false),
Validate at the form boundary with a closure rule, so garbage never reaches persistence:
SqlField::make('sql')
->rule(fn () => function (string $attribute, $value, Closure $fail) {
if (! app(SqlGuard::class)->isSingleStatement($value)) {
$fail('Only a single statement is permitted.');
}
if (! app(SqlGuard::class)->isReadOnlyStatement($value)) {
$fail('Only SELECT statements are permitted.');
}
}),
Parameterize saved queries. Store WHERE tenant_id = :tenant with placeholders, not interpolated values. The field is a text editor; it has no concept of binding, so binding discipline is entirely yours. This also makes saved queries reusable across contexts instead of single-purpose strings.
Wrap the field in your own class from day one:
namespace App\Filament\Fields;
use MrPowerUp82\FilamentSqlField\Forms\Components\SqlField;
final class SqlEditor extends SqlField
{
protected function setUp(): void
{
parent::setUp();
$this->columnSpanFull();
}
}
Every form now references App\Filament\Fields\SqlEditor. When CodeMirror 5 reaches the point you can no longer defend it, you reimplement this class against Monaco or CodeMirror 6 and nothing else in the codebase changes.
Audit table, not log lines. Log user_id, sql, dialect, connection, duration_ms, row_count, status, error, ip, created_at per execution. Filament renders it as an ordinary resource and it becomes your only forensic trail when someone runs SELECT * FROM users at 2am.
Theme and layout consistency. The field follows the Filament panel's dark/light mode, so keep panel darkMode configuration standard rather than per-page overrides. Set an explicit height on the field — the default can be awkward inside modals and slide-overs, where vertical space is tight. Use ->helperText() to surface the active dialect and connection name right under the editor; users writing against the wrong connection is a common and silent failure.
It does not execute, validate, sandbox, or dialect-check anything. The description is explicit that it stores raw SQL strings. Every security control — read-only DB role, statement timeout, row cap, tenant scoping, allow-listing, audit — is a separate build and should be treated as the actual project. Do not let the field reaching a form be mistaken for the feature being ready.
Never run user SQL on the application's default connection. Use a dedicated read replica with a read-only role, per-connection timeouts, and a hard row cap enforced server-side, not just by injecting LIMIT. A LIMIT injection is a convenience, not a control — it does no good against SELECT ... FROM huge_table ORDER BY x before the sort, or against CTEs.
CodeMirror 5 is a legacy major version. Assume eventual migration and keep it cheap via the wrapper-class pattern. Also check it does not collide with any other editor already bundled into your admin assets.
Asset registration is the most likely integration failure. Test it under your real deployment model — Vite build, published vendor assets, CDN — and behind your actual CSP headers. CodeMirror injects inline styles, so a strict style-src policy can silently degrade the editor to an unstyled textarea.
Test inside modals and repeaters, not just on full pages. Livewire re-renders, wire:ignore interactions, and schema components inside Repeater/Builder are where editor components historically break. If the field is inside a ->visible() block that toggles, confirm the editor re-initializes when shown.
Read-only vs. disabled matters. A disabled field does not submit its value. For view pages and "locked" saved queries, use read-only, not disabled, or you will silently lose the SQL on save.
Storage column: use longText/text, not string. Queries grow. Do not JSON-encode a single SQL string.
Escaping on output. Filament tables escape cell values, so rendering results is normally safe. If you render query output as raw HTML anywhere — a summary, an export preview — you own the escaping, and query results are attacker-influenced data.
Pin and vendor. One maintainer, 18 stars, zero dependents. That is fine for a small presentation component and not fine as an unpinned dependency on a critical path. Pin the exact version, and if the SQL console becomes load-bearing, fork or vendor the package — it is small enough that this is hours, not weeks. Read the diff of any release; the 2.0.2 notes ("Support Filament 5," "Improving existing resources") are too thin to be an upgrade guide, and "improving existing resources" may touch published views you have overridden.
Verify the full compatibility matrix yourself: PHP, Laravel, Livewire, Alpine, and whether Filament 4 remains supported after the 2.x bump. Do not infer it from a one-line release note.
Testing: write a Livewire test that mounts a real Filament page containing the field and asserts rendering, plus feature tests for your QueryService — the guard logic is where the bugs and the risk live, and it is fully testable independent of the editor.
Extension points: the field class itself (subclass it), any publishable views/assets, and the surrounding form schema. If you need behavior the field does not expose — autocomplete against your live schema, dialect switching mid-edit, keyword highlighting differences — do not patch the vendor directory; wrap or fork. Autocomplete for tables and columns is advertised, but schema-awareness against your database is not something to assume without verifying how the keyword/table lists are populated.
How can I help you explore Laravel packages today?