🕮17 min read · 3,383 words
This is Part 6 of our AI Code Audit Series — covering the essential audits every team should run when building with AI agentic editors.
The database audit is the final post in this series and in many ways the most consequential. Application code can be refactored. Business logic can be corrected. Security vulnerabilities can be patched. But database schema decisions that reach production with real data are significantly harder and more expensive to fix than any of the problems covered in the previous five posts.
Changing a column’s data type on a table with two million rows requires a careful migration that locks the table, copies data, and validates the result — an operation that takes hours and requires a maintenance window. Adding a NOT NULL constraint to a column that already contains NULL values requires a data cleanup pass before the constraint can be added. Splitting a poorly designed table into two properly normalised tables requires migrating data, updating all application code that touches the affected tables, and careful coordination across the entire team.
AI agentic editors introduce consistent, predictable database problems. This post covers all of them — with the audit checks to find them and the corrections to apply before they’re locked into production.
Why AI Gets Database Design Wrong
AI generates database schemas that are locally reasonable — each table makes sense for the specific feature being built. What’s missing is the global perspective: how tables relate to each other across the full application, whether the data types chosen will accommodate the full range of values the application will encounter, whether constraints correctly enforce business rules at the database level, and whether the schema will support the queries the application will need to run efficiently at scale.
AI also generates schemas in the context of individual prompts. If a feature is built in one session, the schema is designed for that feature. When a related feature is built in a different session, the AI makes independent schema decisions without knowing what was decided before — producing schemas that are internally inconsistent and that will require reconciliation as the application grows.
Database problems are also silent in development. Wrong data types accept values correctly until a value exceeds the column’s capacity. Missing constraints allow invalid data until someone enters it. Poor normalisation works fine until data update anomalies surface months later. By the time these problems are visible, data has already been affected.
Schema Problem 1 — Wrong Data Types
AI chooses data types that work for the test cases in the prompt — not necessarily for the full range of values the column will encounter in production.
// PROBLEMATIC — common AI-generated data type mistakes
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name'); // No length limit — defaults to 255, may be too short for some products
$table->float('price'); // NEVER use float for money — floating point imprecision
$table->integer('stock'); // Signed integer allows negative stock — nonsensical
$table->string('description'); // 255 chars — far too short for product descriptions
$table->string('status'); // Uncontrolled string — should be enum
$table->string('sku'); // No unique constraint — duplicates allowed
$table->integer('weight'); // Integers for weight lose precision (1.5kg becomes 1 or 2)
$table->string('phone'); // Phone numbers are not numeric strings — correct type but needs format
$table->integer('user_id'); // Should be unsignedBigInteger to match id() columns
$table->timestamps();
});
// CORRECT — appropriate data types for each column
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 500); // Longer limit for product names
$table->decimal('price', 10, 2); // ALWAYS decimal for money — exact precision
$table->unsignedInteger('stock')->default(0); // Unsigned prevents negative values
$table->text('description')->nullable(); // TEXT for long content
$table->enum('status', [ // Enum enforces valid values at DB level
'draft',
'active',
'out_of_stock',
'discontinued'
])->default('draft');
$table->string('sku', 100)->unique(); // Unique constraint prevents duplicates
$table->decimal('weight_kg', 8, 3)->nullable(); // Decimal for weight precision
$table->string('phone', 20)->nullable(); // Consistent length for phone numbers
$table->foreignId('user_id')->constrained(); // foreignId() creates correct unsigned bigint
$table->timestamps();
$table->index(['status', 'created_at']); // Composite index for common query
});
The money problem is critical. Float and double types use binary floating point representation which cannot exactly represent most decimal fractions. ₹99.99 stored as a float might retrieve as ₹99.98999999999999 or ₹99.99000000000001. In financial applications, these imprecisions accumulate into real discrepancies. Always use decimal(10, 2) for money — or store amounts as integers in the smallest currency unit (paise) and divide by 100 for display.
// The float money problem demonstrated
$price = 0.1 + 0.2;
var_dump($price === 0.3); // bool(false) — floating point cannot represent 0.3 exactly
var_dump($price); // float(0.30000000000000004)
// Correct approach — store as decimal or integer
// In migration: $table->decimal('price_inr', 10, 2);
// In model: protected $casts = ['price_inr' => 'decimal:2'];
// Or store as integer paise
// $table->unsignedInteger('price_paise'); // ₹99.99 stored as 9999
// Display: number_format($product->price_paise / 100, 2) // "99.99"
Audit check — find wrong data types in existing migrations:
# Find float/double columns that should be decimal
grep -rn "float\|double" database/migrations --include="*.php"
# Every result for financial data is a finding
# Find string columns that should be text
grep -rn "->string(" database/migrations --include="*.php" | \
grep -i "description\|content\|body\|notes\|comment\|bio\|address"
# Find integer foreign keys that should be foreignId/unsignedBigInteger
grep -rn "->integer('.*_id')" database/migrations --include="*.php"
# Check actual column types in database directly
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
COLUMN_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'your_database'
AND DATA_TYPE IN ('float', 'double')
ORDER BY TABLE_NAME, COLUMN_NAME;
Schema Problem 2 — Missing Constraints
AI generates migrations that create tables without the constraints that enforce data integrity at the database level. Application code validates data before it reaches the database — but application bugs, direct database access, failed transactions, and race conditions can all result in invalid data reaching the database without constraint enforcement.
// PROBLEMATIC — missing constraints allow invalid data
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->integer('order_id'); // No foreign key — orphaned records possible
$table->integer('product_id'); // No foreign key — references to deleted products
$table->integer('quantity'); // No check — negative quantities allowed
$table->float('unit_price'); // Float for money (wrong) + no check for negative
$table->timestamps();
});
// CORRECT — constraints enforce integrity at database level
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')
->constrained()
->onDelete('cascade'); // Delete items when order is deleted
$table->foreignId('product_id')
->constrained()
->onDelete('restrict'); // Prevent deleting products with order history
$table->unsignedInteger('quantity'); // Unsigned prevents negative quantities
$table->decimal('unit_price', 10, 2); // Decimal for money
$table->decimal('subtotal', 10, 2); // Stored subtotal — not recalculated
$table->timestamps();
// Composite unique — same product cannot appear twice in same order
$table->unique(['order_id', 'product_id']);
});
// For MySQL 8+ — check constraints for business rules
DB::statement('ALTER TABLE order_items ADD CONSTRAINT chk_quantity_positive CHECK (quantity > 0)');
DB::statement('ALTER TABLE order_items ADD CONSTRAINT chk_price_positive CHECK (unit_price >= 0)');
DB::statement('ALTER TABLE order_items ADD CONSTRAINT chk_subtotal_correct CHECK (ABS(subtotal - (unit_price * quantity)) < 0.01)');
Missing NOT NULL constraints are equally common — AI generates nullable columns for fields that should always have values:
// PROBLEMATIC — nullable columns that should always have values
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable(); // Name should always exist
$table->string('email')->nullable(); // Email should always exist and be unique
$table->string('role'); // String role with no constraint
$table->timestamps();
});
// CORRECT
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name', 255); // NOT NULL by default in Laravel
$table->string('email', 255)->unique(); // NOT NULL + unique
$table->enum('role', ['admin', 'manager', 'staff', 'customer'])
->default('customer'); // Enforced values + default
$table->timestamp('email_verified_at')->nullable(); // Correctly nullable
$table->timestamps();
});
Audit check — find missing constraints:
# Find foreign key columns without constraints
SELECT
col.TABLE_NAME,
col.COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS col
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON col.TABLE_NAME = kcu.TABLE_NAME
AND col.COLUMN_NAME = kcu.COLUMN_NAME
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
WHERE col.TABLE_SCHEMA = 'your_database'
AND col.COLUMN_NAME LIKE '%\_id'
AND col.COLUMN_NAME != 'id'
AND kcu.COLUMN_NAME IS NULL;
# Find nullable columns that likely shouldn't be
grep -rn "->nullable()" database/migrations --include="*.php" | \
grep -i "name\|email\|status\|type\|user_id\|order_id"
Schema Problem 3 — Poor Normalisation
AI agentic editors frequently denormalise data — storing the same information in multiple places, or embedding related data as JSON columns when a proper related table is the correct approach.
// PROBLEMATIC — poorly normalised schema
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
// Customer details duplicated — should reference users table
$table->string('customer_name');
$table->string('customer_email');
$table->string('customer_phone');
// Address stored as JSON — no queryability, no constraints
$table->json('shipping_address');
// Items stored as JSON — cannot be queried, indexed, or constrained
$table->json('items');
$table->decimal('total', 10, 2);
$table->timestamps();
});
// CORRECT — properly normalised with related tables
// orders table — only order-specific data
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('shipping_address_id')->constrained('addresses');
$table->string('order_number', 50)->unique();
$table->enum('status', ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'returned']);
$table->decimal('subtotal', 10, 2);
$table->decimal('discount_amount', 10, 2)->default(0);
$table->decimal('shipping_cost', 10, 2)->default(0);
$table->decimal('tax_amount', 10, 2)->default(0);
$table->decimal('total', 10, 2);
$table->timestamps();
});
// Separate addresses table — reusable, queryable, constrained
Schema::create('addresses', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name', 255);
$table->string('line1', 500);
$table->string('line2', 500)->nullable();
$table->string('city', 100);
$table->string('state', 100);
$table->string('pincode', 10);
$table->string('phone', 20);
$table->boolean('is_default')->default(false);
$table->timestamps();
});
// Separate order_items table — proper line items
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->onDelete('cascade');
$table->foreignId('product_id')->constrained()->onDelete('restrict');
$table->string('product_name', 500); // Snapshot of name at order time
$table->decimal('unit_price', 10, 2); // Snapshot of price at order time
$table->unsignedInteger('quantity');
$table->decimal('subtotal', 10, 2);
$table->timestamps();
});
When JSON columns are acceptable — JSON columns are legitimate for genuinely unstructured, variable, non-queryable data. Product metadata that varies by category, feature flags, third-party API response storage. They're wrong when used for structured data that will be queried, filtered, or joined.
// Legitimate JSON usage — variable metadata that is never queried individually
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
$table->json('specifications')->nullable(); // {'color': 'red', 'material': 'cotton', 'care': '...'}
// specifications vary by category and are displayed as a block — never filtered individually
$table->timestamps();
});
// Illegitimate JSON usage — data that IS queried individually
// WRONG: $table->json('address'); // If you ever do WHERE address->city = 'Mumbai'
// RIGHT: separate addresses table with proper columns
Schema Problem 4 — Unsafe Migration Practices
AI-generated migrations are written for a fresh database — they don't account for the reality that production databases have existing data, ongoing transactions, and table sizes that make some migration operations dangerous or impossible without careful planning.
// DANGEROUS — migrations that are unsafe on production databases with data
// Danger 1: Adding NOT NULL column without default to table with existing rows
Schema::table('users', function (Blueprint $table) {
$table->string('phone')->notNull(); // FAILS if any existing row has no phone
});
// Danger 2: Changing column type on large table
Schema::table('products', function (Blueprint $table) {
$table->decimal('price', 10, 2)->change(); // Full table rewrite — locks table
});
// Danger 3: Adding unique index to column with existing duplicates
Schema::table('users', function (Blueprint $table) {
$table->unique('email'); // FAILS if duplicate emails exist
});
// SAFE — migrations that account for existing data
// Safe: Adding NOT NULL column — provide default or make nullable first
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 20)->nullable()->after('email'); // Add as nullable
});
// Then in a second migration after backfilling data:
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 20)->notNull()->change(); // Make NOT NULL after data exists
});
// Safe: Large table column changes — use pt-online-schema-change or gh-ost
// For tables > 1M rows, never use standard ALTER TABLE in production
// Use: pt-online-schema-change --alter "MODIFY price DECIMAL(10,2)" D=mydb,t=products
// Safe: Adding unique index — clean duplicates first
// Step 1: Identify and resolve duplicates
$duplicates = DB::select("
SELECT email, COUNT(*) as count
FROM users
GROUP BY email
HAVING count > 1
");
// Handle duplicates in application logic or data cleanup script
// Step 2: Then add the constraint
Schema::table('users', function (Blueprint $table) {
$table->unique('email');
});
// Always wrap related schema changes in transactions
DB::transaction(function () {
Schema::table('orders', function (Blueprint $table) {
$table->string('order_number', 50)->nullable()->after('id');
});
// Backfill existing rows
DB::table('orders')->whereNull('order_number')->chunkById(500, function ($orders) {
foreach ($orders as $order) {
DB::table('orders')
->where('id', $order->id)
->update(['order_number' => 'ORD-' . str_pad($order->id, 8, '0', STR_PAD_LEFT)]);
}
});
Schema::table('orders', function (Blueprint $table) {
$table->string('order_number', 50)->notNull()->unique()->change();
});
});
Audit check — find dangerous migration patterns:
# Find migrations adding NOT NULL columns without defaults
grep -rn "notNull\(\)\|->nullable(false)" database/migrations --include="*.php"
# Review each — is this a new table (safe) or existing table (potentially dangerous)?
# Find ->change() calls on potentially large tables
grep -rn "->change()" database/migrations --include="*.php"
# Find unique index additions that might fail on existing data
grep -rn "->unique()" database/migrations --include="*.php" | \
grep -v "Schema::create" # Unique on existing tables needs data audit first
Query Problem 1 — Unsafe Raw Queries
Beyond SQL injection (covered in the security audit), AI-generated raw queries have additional problems — incorrect escaping of special characters, wrong handling of NULL values, and improper handling of dynamic ORDER BY and column names.
// PROBLEMATIC — unsafe dynamic query construction
public function getSortedProducts(string $sortColumn, string $sortDirection): Collection
{
// SQL injection via column name — not caught by parameter binding
return DB::select("SELECT * FROM products ORDER BY {$sortColumn} {$sortDirection}");
}
// PROBLEMATIC — NULL handling errors
public function findByOptionalCategory(?int $categoryId): Collection
{
if ($categoryId) {
return Product::where('category_id', $categoryId)->get();
}
// Wrong: this returns products WITH a category, not without one
return Product::where('category_id', null)->get(); // Should use whereNull()
}
// CORRECT — whitelist dynamic column names
public function getSortedProducts(string $sortColumn, string $sortDirection): Collection
{
$allowedColumns = ['name', 'price', 'created_at', 'stock'];
$allowedDirections = ['asc', 'desc'];
$column = in_array($sortColumn, $allowedColumns) ? $sortColumn : 'created_at';
$direction = in_array(strtolower($sortDirection), $allowedDirections) ? $sortDirection : 'desc';
return Product::orderBy($column, $direction)->get();
}
// CORRECT — proper NULL handling
public function findByOptionalCategory(?int $categoryId): Collection
{
return Product::when(
$categoryId !== null,
fn($q) => $q->where('category_id', $categoryId),
fn($q) => $q->whereNull('category_id')
)->get();
}
// CORRECT — handle NULL in comparisons
// WRONG: WHERE column = NULL (always returns no rows in SQL)
// RIGHT: WHERE column IS NULL
Product::whereNull('deleted_at')->get(); // Correct
Product::where('deleted_at', null)->get(); // Also correct — Eloquent handles this
// DB::select("WHERE deleted_at = NULL") — WRONG — never matches in SQL
Query Problem 2 — Transaction Misuse
AI-generated code frequently omits transactions for operations that must be atomic — multiple related database changes that must all succeed or all fail together.
// PROBLEMATIC — related operations without transaction
public function transferStock(int $fromProductId, int $toProductId, int $quantity): void
{
$fromProduct = Product::findOrFail($fromProductId);
$toProduct = Product::findOrFail($toProductId);
// If application crashes between these two updates — stock is lost
$fromProduct->decrement('stock', $quantity);
$toProduct->increment('stock', $quantity);
StockTransfer::create([
'from_product_id' => $fromProductId,
'to_product_id' => $toProductId,
'quantity' => $quantity,
]);
}
// CORRECT — atomic transaction
public function transferStock(int $fromProductId, int $toProductId, int $quantity): void
{
DB::transaction(function () use ($fromProductId, $toProductId, $quantity) {
// Lock rows for update — prevents race conditions
$fromProduct = Product::lockForUpdate()->findOrFail($fromProductId);
$toProduct = Product::lockForUpdate()->findOrFail($toProductId);
if ($fromProduct->stock < $quantity) {
throw new InsufficientStockException(
"Insufficient stock in product {$fromProductId}"
);
}
$fromProduct->decrement('stock', $quantity);
$toProduct->increment('stock', $quantity);
StockTransfer::create([
'from_product_id' => $fromProductId,
'to_product_id' => $toProductId,
'quantity' => $quantity,
'transferred_by' => auth()->id(),
]);
// If any of the above fails — entire transaction rolls back
});
}
// CORRECT — transaction with manual rollback control
public function processPayment(Order $order, array $paymentData): Payment
{
DB::beginTransaction();
try {
$payment = Payment::create([
'order_id' => $order->id,
'amount' => $order->total,
'status' => 'processing',
]);
$gatewayResponse = $this->gateway->charge($paymentData, $order->total);
$payment->update([
'status' => 'completed',
'gateway_payment_id' => $gatewayResponse->id,
]);
$order->update(['status' => 'confirmed', 'payment_id' => $payment->id]);
DB::commit();
return $payment;
} catch (PaymentGatewayException $e) {
DB::rollBack();
throw $e;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Payment processing failed', ['order_id' => $order->id, 'error' => $e->getMessage()]);
throw $e;
}
}
Audit check — find multi-step operations without transactions:
# Find methods that do multiple DB writes without transaction wrapper
grep -rn "->save()\|->update(\|->create(\|->delete()" app/Services --include="*.php" -l | \
while read file; do
count=$(grep -c "->save()\|->update(\|->create(\|->delete()" "$file")
if [ "$count" -gt 2 ]; then
transactions=$(grep -c "DB::transaction\|beginTransaction" "$file")
if [ "$transactions" -eq 0 ]; then
echo "WARNING: $file has $count DB writes but no transactions"
fi
fi
done
The Complete Database Audit Checklist
Data Types
- All monetary values use decimal — no float or double for money
- All foreign key columns use unsignedBigInteger or foreignId()
- String columns have appropriate length limits for their content
- Long text content uses text or longText — not string
- Status and type columns use enum with valid values defined
- Decimal precision is appropriate — decimal(10,2) for currency
Constraints and Integrity
- All foreign key relationships have database-level constraints
- onDelete behaviour is explicitly defined for all foreign keys
- Columns that should always have values are NOT NULL
- Columns that must be unique have unique constraints
- Business rule constraints use CHECK constraints where supported
- No orphaned records exist in any related table
Normalisation
- No data is stored in multiple places without clear justification
- JSON columns are only used for genuinely variable unqueried data
- Related entities have proper separate tables
- Historical snapshots (order item price) are correctly stored
Migrations
- All migrations are reversible — down() methods are implemented
- NOT NULL columns added to existing tables include a default value
- Large table changes use safe migration strategies
- Unique constraints are added only after verifying no duplicates exist
- Migrations that modify data are wrapped in transactions
Query Safety
- All dynamic column names in raw queries use whitelist validation
- NULL comparisons use whereNull() not where('column', null) in raw SQL
- Multi-step write operations are wrapped in transactions
- Concurrent-access operations use lockForUpdate() where needed
- No SELECT * in application queries — explicit column selection
Running the Database Audit — Tooling
# Laravel Enlightn — includes database-specific checks
php artisan enlightn --ci
# Check for missing indexes on foreign keys
composer require --dev beyondcode/laravel-query-detector
# Analyse actual database schema vs migrations
php artisan migrate:status # Ensure all migrations are run
php artisan schema:dump # Dump current schema for review
# MySQL Workbench — visual schema analysis
# Connect to your database and use Database > Reverse Engineer
# Visual schema shows missing relationships and unusual data types
# Check for NULL violations in existing data before adding NOT NULL
SELECT COUNT(*) FROM users WHERE name IS NULL;
SELECT COUNT(*) FROM users WHERE email IS NULL;
# If count > 0 — must backfill before adding NOT NULL constraint
# Find tables without primary keys
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES t
WHERE TABLE_SCHEMA = 'your_database'
AND TABLE_TYPE = 'BASE TABLE'
AND NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
WHERE tc.TABLE_SCHEMA = t.TABLE_SCHEMA
AND tc.TABLE_NAME = t.TABLE_NAME
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
);
# Find columns storing numbers as strings
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'your_database'
AND DATA_TYPE = 'varchar'
AND (COLUMN_NAME LIKE '%amount%'
OR COLUMN_NAME LIKE '%price%'
OR COLUMN_NAME LIKE '%cost%'
OR COLUMN_NAME LIKE '%total%'
OR COLUMN_NAME LIKE '%fee%');
Completing the Series — Updating Post 1 With Internal Links
All six audits are now complete. Each covers a distinct category of problem that AI agentic editors introduce consistently — and together they provide a comprehensive framework for ensuring that AI-assisted development produces code that is correct, secure, maintainable, performant, and built on a solid data foundation.
The six audits in order of when to run them:
- Database Audit — run first, on every migration, before data reaches production
- Security Audit — run on every significant feature before merging
- Business Logic Audit — run before every release
- Performance Audit — run before launch and quarterly thereafter
- Code Quality Audit — run monthly, automate the mechanical parts in CI
- Architecture Audit — run quarterly on the full codebase
If you want a complete audit run on your existing codebase — database schema, security, business logic, performance, and code quality — with a prioritised remediation plan, our team at Softcrony is happy to help.
Leave a comment