diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c8cb3d6..a70772f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,8 +13,11 @@ jobs:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
- laravel: ['11.*', '12.*', '13.*']
+ laravel: ['10.*', '11.*', '12.*', '13.*']
include:
+ - laravel: '10.*'
+ testbench: '8.*'
+ pest: '^2.0'
- laravel: '11.*'
testbench: '9.*'
- laravel: '12.*'
@@ -25,6 +28,9 @@ jobs:
# Laravel 13 requires PHP 8.3.
- php: '8.2'
laravel: '13.*'
+ # Laravel 10 predates PHP 8.4 and was never certified against it.
+ - php: '8.4'
+ laravel: '10.*'
name: PHP ${{ matrix.php }} · Laravel ${{ matrix.laravel }}
@@ -37,15 +43,20 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3
coverage: none
- # Laravel 11's security window closed in March 2026, so Composer will not
- # load any 11.x release at all. The package still supports Laravel 11 and
- # this leg still proves it, but the framework it tests against is
- # unpatched — which is Composer's point, and why this is scoped to the one
- # leg that needs it rather than written into composer.json.
- - name: Allow Laravel 11's security advisories
- if: matrix.laravel == '11.*'
+ # The security windows for Laravel 10 and 11 have closed, so Composer will
+ # not load any 10.x or 11.x release at all. The package still supports
+ # both and these legs still prove it, but the frameworks they test against
+ # are unpatched — which is Composer's point, and why this is scoped to the
+ # legs that need it rather than written into composer.json.
+ - name: Allow the unpatched framework lines' security advisories
+ if: matrix.laravel == '10.*' || matrix.laravel == '11.*'
run: composer config --global policy.advisories.block false
+ # Testbench 8 pins PHPUnit 10, which Pest 3 and 4 do not support.
+ - name: Pin Pest to the PHPUnit 10 line
+ if: matrix.pest != ''
+ run: composer require --dev "pestphp/pest:${{ matrix.pest }}" --no-interaction --no-update
+
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5a511a..53e1437 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to Laravel DynamicTable are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- Laravel 10 support. `Schema::getColumns()` and `Schema::getIndexes()` are
+ Laravel 11 additions, so the new `Support\SchemaIntrospector` uses them when
+ they exist and otherwise runs the equivalent driver queries (SQLite, MySQL,
+ MariaDB, PostgreSQL, SQL Server) itself. `doctrine/dbal` is still never
+ required. The test matrix now covers Laravel 10 on PHP 8.2 and 8.3.
+
## [1.0.0] — 2026-08-30
First public release.
diff --git a/README.md b/README.md
index 37c9cae..22217d3 100644
--- a/README.md
+++ b/README.md
@@ -152,7 +152,7 @@ Full checklist in [docs/security.md](docs/security.md).
## Requirements
-PHP 8.2+ · Laravel 11, 12 or 13 · MySQL, MariaDB, PostgreSQL or SQLite.
+PHP 8.2+ · Laravel 10, 11, 12 or 13 · MySQL, MariaDB, PostgreSQL or SQLite.
No Livewire. No Alpine. No jQuery. No npm. No CSS framework required.
(It works happily *inside* a Livewire app — it just doesn't need one. The
diff --git a/composer.json b/composer.json
index 88044cf..89cc89d 100644
--- a/composer.json
+++ b/composer.json
@@ -26,17 +26,17 @@
"source": "https://github.com/shwaeki/DynamicTable"
},
"require": {
- "illuminate/contracts": "^11.0|^12.0|^13.0",
- "illuminate/database": "^11.0|^12.0|^13.0",
- "illuminate/http": "^11.0|^12.0|^13.0",
- "illuminate/support": "^11.0|^12.0|^13.0",
- "illuminate/view": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
+ "illuminate/database": "^10.0|^11.0|^12.0|^13.0",
+ "illuminate/http": "^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
+ "illuminate/view": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.2",
- "symfony/finder": "^7.0|^8.0"
+ "symfony/finder": "^6.2|^7.0|^8.0"
},
"require-dev": {
- "orchestra/testbench": "^9.0|^10.0|^11.0",
- "pestphp/pest": "^3.0|^4.0",
+ "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0",
+ "pestphp/pest": "^2.0|^3.0|^4.0",
"larastan/larastan": "^2.9|^3.0",
"laravel/pint": "^1.18"
},
diff --git a/docs/architecture.md b/docs/architecture.md
index 367f9bf..48d6871 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -165,11 +165,13 @@ dropped, a warning is surfaced, and the table still renders.
## 9. Supported versions
-- **PHP 8.2+** — matches the floor of every currently supported Laravel release.
-- **Laravel 11, 12 and 13.** Laravel 11 is the floor because the package relies
- on `Schema::getColumns()`, which removed the `doctrine/dbal` requirement for
- schema introspection. Supporting Laravel 10 would mean carrying dbal for one
- release line; that cost is not worth paying.
+- **PHP 8.2+.** Laravel 10 itself also runs on PHP 8.1, but 8.1 is end of life,
+ so the package does not carry it.
+- **Laravel 10, 11, 12 and 13.** Laravel 11 introduced `Schema::getColumns()`
+ and `Schema::getIndexes()`, which read the schema natively instead of through
+ `doctrine/dbal`. Rather than require dbal on Laravel 10, `SchemaIntrospector`
+ uses those methods when they exist and otherwise runs the same driver queries
+ itself — so the package never pulls dbal in, on any version.
- Databases: MySQL, MariaDB, PostgreSQL, SQLite. No database-specific SQL is
emitted; `LIKE` is used for search because it is the portable option, and the
docs explain when to reach for a real search index instead.
diff --git a/docs/installation.md b/docs/installation.md
index 6ef80f9..13a78e5 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -5,7 +5,7 @@
| | |
|---|---|
| PHP | 8.2+ |
-| Laravel | 11, 12 or 13 |
+| Laravel | 10, 11, 12 or 13 |
| Database | MySQL, MariaDB, PostgreSQL or SQLite |
| JavaScript build step | none |
| CSS framework | none required |
diff --git a/phpunit.xml b/phpunit.xml
index 8c9c1ed..8863e35 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -18,6 +18,8 @@
+
+
diff --git a/src/Metadata/MetadataEngine.php b/src/Metadata/MetadataEngine.php
index 212e9ad..a9bf87e 100644
--- a/src/Metadata/MetadataEngine.php
+++ b/src/Metadata/MetadataEngine.php
@@ -7,11 +7,11 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
+use Shwaeki\DynamicTable\Support\SchemaIntrospector;
use Throwable;
use UnitEnum;
@@ -30,6 +30,8 @@ class MetadataEngine
/** @var array */
protected array $pathMemo = [];
+ protected SchemaIntrospector $schema;
+
/** Method names on Eloquent/base classes that must never be probed as relations. */
protected const SKIP_METHODS = [
'newQuery', 'newModelQuery', 'newQueryWithoutRelationships', 'newCollection',
@@ -46,7 +48,10 @@ class MetadataEngine
'getQualifiedKeyName', 'query', 'on', 'onWriteConnection', 'all', 'with',
];
- public function __construct(protected ?string $storeName = null) {}
+ public function __construct(protected ?string $storeName = null, ?SchemaIntrospector $schema = null)
+ {
+ $this->schema = $schema ?? new SchemaIntrospector;
+ }
/**
* @param class-string|Model $model
@@ -305,7 +310,7 @@ protected function introspect(Model $model): array
$columns = [];
try {
- $columns = Schema::connection($connection)->getColumns($table);
+ $columns = $this->schema->columns($connection, $table);
} catch (Throwable) {
$columns = [];
}
@@ -313,7 +318,7 @@ protected function introspect(Model $model): array
$indexed = [];
try {
- foreach (Schema::connection($connection)->getIndexes($table) as $index) {
+ foreach ($this->schema->indexes($connection, $table) as $index) {
foreach ($index['columns'] as $column) {
$indexed[$column] = true;
}
diff --git a/src/Support/SchemaIntrospector.php b/src/Support/SchemaIntrospector.php
new file mode 100644
index 0000000..4d2ba98
--- /dev/null
+++ b/src/Support/SchemaIntrospector.php
@@ -0,0 +1,347 @@
+ ..., 'type_name' => ..., 'type' => ..., 'nullable' => ...]`.
+ *
+ * @return array>
+ */
+ public function columns(?string $connection, string $table): array
+ {
+ $builder = Schema::connection($connection);
+
+ if (method_exists($builder, 'getColumns')) {
+ return $builder->getColumns($table);
+ }
+
+ $database = DB::connection($connection);
+ $prefixed = $database->getTablePrefix().$table;
+
+ return match ($database->getDriverName()) {
+ 'sqlite' => $this->sqliteColumns($database, $prefixed),
+ 'mysql', 'mariadb' => $this->mysqlColumns($database, $prefixed),
+ 'pgsql' => $this->postgresColumns($database, $prefixed),
+ 'sqlsrv' => $this->sqlServerColumns($database, $prefixed),
+ default => [],
+ };
+ }
+
+ /**
+ * Indexes of a table, as `['name' => ..., 'columns' => [...], 'unique' => ..., 'primary' => ...]`.
+ *
+ * @return array>
+ */
+ public function indexes(?string $connection, string $table): array
+ {
+ $builder = Schema::connection($connection);
+
+ if (method_exists($builder, 'getIndexes')) {
+ return $builder->getIndexes($table);
+ }
+
+ $database = DB::connection($connection);
+ $prefixed = $database->getTablePrefix().$table;
+
+ return match ($database->getDriverName()) {
+ 'sqlite' => $this->sqliteIndexes($database, $prefixed),
+ 'mysql', 'mariadb' => $this->mysqlIndexes($database, $prefixed),
+ 'pgsql' => $this->postgresIndexes($database, $prefixed),
+ 'sqlsrv' => $this->sqlServerIndexes($database, $prefixed),
+ default => [],
+ };
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* SQLite */
+ /* ------------------------------------------------------------------ */
+
+ /** @return array> */
+ protected function sqliteColumns(Connection $connection, string $table): array
+ {
+ $columns = [];
+
+ foreach ($connection->select('pragma table_info('.$this->quote($table).')') as $row) {
+ $columns[] = $this->column(
+ (string) $row->name,
+ (string) $row->type,
+ ! $row->notnull,
+ );
+ }
+
+ return $columns;
+ }
+
+ /** @return array> */
+ protected function sqliteIndexes(Connection $connection, string $table): array
+ {
+ $indexes = [];
+
+ // An `integer primary key` column is the rowid alias and never appears in
+ // index_list, so the primary key is read from the column list instead.
+ $primary = [];
+
+ foreach ($connection->select('pragma table_info('.$this->quote($table).')') as $row) {
+ if ((int) $row->pk > 0) {
+ $primary[(int) $row->pk] = (string) $row->name;
+ }
+ }
+
+ if ($primary !== []) {
+ ksort($primary);
+
+ $indexes[] = [
+ 'name' => 'primary',
+ 'columns' => array_values($primary),
+ 'type' => null,
+ 'unique' => true,
+ 'primary' => true,
+ ];
+ }
+
+ foreach ($connection->select('pragma index_list('.$this->quote($table).')') as $index) {
+ $name = (string) $index->name;
+ $columns = [];
+
+ foreach ($connection->select('pragma index_info('.$this->quote($name).')') as $column) {
+ if ($column->name !== null) {
+ $columns[] = (string) $column->name;
+ }
+ }
+
+ $indexes[] = [
+ 'name' => $name,
+ 'columns' => $columns,
+ 'type' => null,
+ 'unique' => (bool) $index->unique,
+ 'primary' => ($index->origin ?? null) === 'pk',
+ ];
+ }
+
+ return $indexes;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* MySQL / MariaDB */
+ /* ------------------------------------------------------------------ */
+
+ /** @return array> */
+ protected function mysqlColumns(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select column_name as name, column_type as full_type, data_type as type_name, '
+ .'is_nullable as nullable from information_schema.columns '
+ .'where table_schema = database() and table_name = ? order by ordinal_position',
+ [$table],
+ );
+
+ $columns = [];
+
+ foreach ($rows as $row) {
+ $columns[] = $this->column(
+ (string) $row->name,
+ (string) $row->full_type,
+ $this->nullable($row->nullable),
+ (string) $row->type_name,
+ );
+ }
+
+ return $columns;
+ }
+
+ /** @return array> */
+ protected function mysqlIndexes(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select index_name as name, column_name as column_name, non_unique as non_unique '
+ .'from information_schema.statistics '
+ .'where table_schema = database() and table_name = ? order by index_name, seq_in_index',
+ [$table],
+ );
+
+ return $this->group($rows, fn (object $row): bool => ! (bool) $row->non_unique);
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* PostgreSQL */
+ /* ------------------------------------------------------------------ */
+
+ /** @return array> */
+ protected function postgresColumns(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select column_name as name, udt_name as type_name, is_nullable as nullable, '
+ .'character_maximum_length as length from information_schema.columns '
+ .'where table_schema = current_schema() and table_name = ? order by ordinal_position',
+ [$table],
+ );
+
+ $columns = [];
+
+ foreach ($rows as $row) {
+ $type = (string) $row->type_name;
+
+ $columns[] = $this->column(
+ (string) $row->name,
+ $row->length !== null ? $type.'('.(int) $row->length.')' : $type,
+ $this->nullable($row->nullable),
+ $type,
+ );
+ }
+
+ return $columns;
+ }
+
+ /** @return array> */
+ protected function postgresIndexes(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select ic.relname as name, a.attname as column_name, ix.indisunique as is_unique, '
+ .'ix.indisprimary as is_primary from pg_index ix '
+ .'join pg_class c on c.oid = ix.indrelid '
+ .'join pg_class ic on ic.oid = ix.indexrelid '
+ .'join pg_attribute a on a.attrelid = c.oid and a.attnum = any(ix.indkey) '
+ .'where c.relname = ? and c.relnamespace = (select oid from pg_namespace where nspname = current_schema())',
+ [$table],
+ );
+
+ return $this->group(
+ $rows,
+ fn (object $row): bool => (bool) $row->is_unique,
+ fn (object $row): bool => (bool) $row->is_primary,
+ );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* SQL Server */
+ /* ------------------------------------------------------------------ */
+
+ /** @return array> */
+ protected function sqlServerColumns(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select column_name as name, data_type as type_name, is_nullable as nullable, '
+ .'character_maximum_length as length from information_schema.columns '
+ .'where table_name = ? order by ordinal_position',
+ [$table],
+ );
+
+ $columns = [];
+
+ foreach ($rows as $row) {
+ $type = (string) $row->type_name;
+ $length = $row->length !== null && (int) $row->length > 0 ? (int) $row->length : null;
+
+ $columns[] = $this->column(
+ (string) $row->name,
+ $length !== null ? $type.'('.$length.')' : $type,
+ $this->nullable($row->nullable),
+ $type,
+ );
+ }
+
+ return $columns;
+ }
+
+ /** @return array> */
+ protected function sqlServerIndexes(Connection $connection, string $table): array
+ {
+ $rows = $connection->select(
+ 'select i.name as name, col.name as column_name, i.is_unique as is_unique, '
+ .'i.is_primary_key as is_primary from sys.indexes i '
+ .'join sys.index_columns ic on ic.object_id = i.object_id and ic.index_id = i.index_id '
+ .'join sys.columns col on col.object_id = ic.object_id and col.column_id = ic.column_id '
+ .'where i.object_id = object_id(?) order by i.name, ic.key_ordinal',
+ [$table],
+ );
+
+ return $this->group(
+ $rows,
+ fn (object $row): bool => (bool) $row->is_unique,
+ fn (object $row): bool => (bool) $row->is_primary,
+ );
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Shared */
+ /* ------------------------------------------------------------------ */
+
+ /** @return array */
+ protected function column(string $name, string $type, bool $nullable, ?string $typeName = null): array
+ {
+ $type = strtolower(trim($type));
+
+ return [
+ 'name' => $name,
+ 'type_name' => strtolower(trim($typeName ?? explode('(', $type)[0])),
+ 'type' => $type,
+ 'nullable' => $nullable,
+ ];
+ }
+
+ /**
+ * Collapse one row per index column into one entry per index.
+ *
+ * @param array $rows
+ * @param (callable(object): bool)|null $unique
+ * @param (callable(object): bool)|null $primary
+ * @return array>
+ */
+ protected function group(array $rows, ?callable $unique = null, ?callable $primary = null): array
+ {
+ $indexes = [];
+
+ foreach ($rows as $row) {
+ $name = (string) $row->name;
+
+ $indexes[$name] ??= [
+ 'name' => $name,
+ 'columns' => [],
+ 'type' => null,
+ 'unique' => $unique !== null && $unique($row),
+ 'primary' => $primary !== null
+ ? $primary($row)
+ : strtolower($name) === 'primary',
+ ];
+
+ if ($row->column_name !== null) {
+ $indexes[$name]['columns'][] = (string) $row->column_name;
+ }
+ }
+
+ return array_values($indexes);
+ }
+
+ /** Information-schema nullability is the string `YES`/`NO`; SQL Server reports a bit. */
+ protected function nullable(mixed $value): bool
+ {
+ if (is_string($value)) {
+ return strtoupper($value) === 'YES';
+ }
+
+ return (bool) $value;
+ }
+
+ /** Quote a table or index name for a PRAGMA, which takes no bindings. */
+ protected function quote(string $name): string
+ {
+ return "'".str_replace("'", "''", $name)."'";
+ }
+}
diff --git a/tests/Unit/SchemaIntrospectorTest.php b/tests/Unit/SchemaIntrospectorTest.php
new file mode 100644
index 0000000..1d86d91
--- /dev/null
+++ b/tests/Unit/SchemaIntrospectorTest.php
@@ -0,0 +1,57 @@
+> */
+ public function legacyColumns(string $table): array
+ {
+ return $this->sqliteColumns(DB::connection(), $table);
+ }
+
+ /** @return array> */
+ public function legacyIndexes(string $table): array
+ {
+ return $this->sqliteIndexes(DB::connection(), $table);
+ }
+}
+
+it('describes columns without the native schema methods', function (): void {
+ $columns = collect((new LegacySchemaIntrospector)->legacyColumns('users'))
+ ->keyBy('name');
+
+ expect($columns)->toHaveKey('id')
+ ->and($columns['id']['type_name'])->toBe('integer')
+ ->and($columns['id']['nullable'])->toBeFalse()
+ ->and($columns['name']['type_name'])->toBe('varchar')
+ ->and($columns['name']['type'])->toBe('varchar')
+ ->and($columns['name']['nullable'])->toBeFalse();
+});
+
+it('matches the native column list on this Laravel version', function (): void {
+ if (! method_exists(Schema::connection(null), 'getColumns')) {
+ $this->markTestSkipped('Laravel 10 has no native getColumns() to compare against.');
+ }
+
+ $native = collect(Schema::getColumns('users'))->pluck('type_name', 'name')->all();
+ $legacy = collect((new LegacySchemaIntrospector)->legacyColumns('users'))->pluck('type_name', 'name')->all();
+
+ expect($legacy)->toBe($native);
+});
+
+it('reports indexed columns without the native schema methods', function (): void {
+ $columns = collect((new LegacySchemaIntrospector)->legacyIndexes('users'))
+ ->flatMap(fn (array $index): array => $index['columns'])
+ ->unique()
+ ->values();
+
+ expect($columns)->toContain('id')->toContain('email');
+});