Breaking Changes Log
Mini framework is in active internal development. We prioritize clean, simple code over backward compatibility. When we find a better approach, we remove the old implementation rather than maintain redundant code.
This log tracks breaking changes for reference when reviewing old code or conversations.
Database: PartialQuery API - Separated withEntityClass() from withHydrator() (2025-01-24)
BREAKING CHANGE
Split withHydrator() into two separate methods for better API clarity:
withEntityClass(string $class, array|false $constructorArgs = false)- Framework-managed hydrationwithHydrator(\Closure $hydrator)- Custom closure hydration only
What Changed
Before:
// Class string with constructor args
$users = db()->table('users')->withHydrator(User::class, [db()->getPdo()]);
// Class string without constructor
$users = db()->table('users')->withHydrator(User::class, false);
// Closure
$users = db()->table('users')->withHydrator(
fn($id, $name, $email) => new User($id, $name, $email)
);
After:
// Use withEntityClass() for class-based hydration
$users = db()->table('users')->withEntityClass(User::class, [db()->getPdo()]);
// Skip constructor with false
$users = db()->table('users')->withEntityClass(User::class, false);
// Use withHydrator() for closures ONLY
$users = db()->table('users')->withHydrator(
fn($id, $name, $email) => new User($id, $name, $email)
);
Migration
Search and replace:
- Find:
->withHydrator(SomeClass::class, false)→ Replace:->withEntityClass(SomeClass::class, false) - Find:
->withHydrator(SomeClass::class, [→ Replace:->withEntityClass(SomeClass::class, [ - Find:
->withHydrator(SomeClass::class)→ Replace:->withEntityClass(SomeClass::class) - Closures still use
->withHydrator(fn(...) => ...)
Why this change:
- Cleaner API - entity class handling vs custom hydration are fundamentally different
- Better type safety -
withHydrator()now only accepts\Closure - No more reserved values (
trueis no longer reserved) - Paves the way for future attribute-based hydration on entity classes
Database: Added insert() and upsert() + ModelTrait (2025-01-21)
NEW FEATURES
Added convenient methods for inserting and upserting rows, plus an Eloquent-style ModelTrait for Active Record pattern support.
What's New
- DatabaseInterface::insert(): Simple INSERT operation returning last insert ID
db()->insert('users', ['name' => 'John', 'email' => 'john@example.com'])- Returns the new row's ID (string)
- Throws exception on failure (unique constraint violation, etc.)
- DatabaseInterface::upsert(): INSERT or UPDATE on conflict
db()->upsert('users', ['email' => 'john@example.com', 'name' => 'John'], 'email')- Supports composite unique keys:
db()->upsert('prefs', $data, 'user_id', 'key') - Dialect-specific SQL generation (MySQL, Postgres, SQLite, SQL Server, Oracle)
- Returns affected rows (1 for insert/update, 0 for no change)
- ModelTrait: Eloquent-style Active Record pattern with generic template support
- Entity pattern:
$user->save(),$user->delete()- instance methods - Repository pattern:
Users::save($user),Users::delete($user)- static methods on POPO User::find($id)- Find by primary key with typed return (User|null)User::query()- Returns typedPartialQuery<User>for composable scopes@template T of object- Full PHPDoc generic support for type safety- Automatic hydration via reflection (no constructor calls needed)
- Requires:
getTableName(),getPrimaryKey(),getEntityClass(),dehydrate(object $entity)methods
- Entity pattern:
Migration
No breaking changes - these are pure additions.
Using insert():
// Before
db()->exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
['John', 'john@example.com']
);
$id = db()->lastInsertId();
// After
$id = db()->insert('users', ['name' => 'John', 'email' => 'john@example.com']);
Using upsert():
// Insert or update based on email uniqueness
db()->upsert('users', [
'email' => 'john@example.com',
'name' => 'John Doe'
], 'email');
Using ModelTrait (Entity pattern):
class User {
use ModelTrait;
public ?int $id = null;
public string $name;
public string $email;
protected static function getTableName(): string { return 'users'; }
protected static function getPrimaryKey(): string { return 'id'; }
protected static function getEntityClass(): string { return self::class; }
protected static function dehydrate(object $entity): array {
return ['id' => $entity->id, 'name' => $entity->name, 'email' => $entity->email];
}
}
$user = new User();
$user->name = 'John';
$user->save(); // INSERT
$user->name = 'Updated';
$user->save(); // UPDATE
$user->delete();
Using ModelTrait (Repository pattern with POPO):
class User {
public ?int $id = null;
public string $name;
}
/**
* @use ModelTrait<User>
*/
class Users {
use ModelTrait;
protected static function getTableName(): string { return 'users'; }
protected static function getPrimaryKey(): string { return 'id'; }
protected static function getEntityClass(): string { return User::class; }
protected static function dehydrate(object $entity): array {
return ['id' => $entity->id, 'name' => $entity->name];
}
}
$user = new User();
$user->name = 'John';
Users::save($user); // INSERT
$found = Users::find(1);
$found->name = 'Updated';
Users::save($found); // UPDATE
Users::delete($found);
See examples/upsert.php, examples/model-trait.php, and examples/model-trait-repository.php for complete examples.
Database: Simplified query() + Object Hydration + SQL Dialects (2025-01-21)
BREAKING CHANGE + NEW FEATURES
Simplified database interface by making query() return iterable and removing queryStream(). Added object hydration with full PHPDoc generic support. Added SQL dialect system for database-specific SQL generation.
Breaking Changes
- query() now returns
iterable(yields rows) instead ofarray- Use
iterator_to_array($db->query(...))if you need an actual array - More memory efficient - streams by default instead of buffering
- Use
- queryStream() removed - no longer needed since
query()streams- Replace
$db->queryStream(...)with$db->query(...)
- Replace
What's New
- PartialQuery::withHydrator(): Convert rows to typed objects
- Class name:
->withHydrator(User::class, $constructorArgs) - Skip constructor:
->withHydrator(User::class, false)- usesnewInstanceWithoutConstructor() - Uses
ReflectionClass::newInstanceArgs()for efficiency - Uses reflection to set private/protected/public properties
- Reflection properties cached per iteration (thread-safe, no static state)
- Catches
ReflectionExceptionand throwsRuntimeExceptionwith context - Closure:
->withHydrator(fn(...$row) => new User(...$row)) - Reserved:
->withHydrator(User::class, true)throwsInvalidArgumentException(future use)
- Class name:
- Generic template support:
@template Tfor type-safe IDE supportPartialQuery<User>- IDE knows iteration yields User objectsone()returnsT|null- type-safe single row fetchgetIterator()returns\Generator<int, T, mixed, void>- proper generator typing
- Composable with scopes:
User::all()can returnPartialQuery<User> - Works with mutations: Hydration doesn't prevent
delete()orupdate() - Cleared by select(): Selecting specific columns clears hydrator and returns
PartialQuery<array>
Migration
// Before
$users = $db->query("SELECT * FROM users");
foreach ($users as $user) { ... }
// After - same usage! Iteration works identically
$users = $db->query("SELECT * FROM users");
foreach ($users as $user) { ... }
// If you actually need an array
$users = iterator_to_array($db->query("SELECT * FROM users"));
// queryStream() removed
$stream = $db->queryStream("SELECT * FROM users"); // Remove this
$stream = $db->query("SELECT * FROM users"); // Use this
See examples/partial-query-hydrator.php for hydration examples.
Database: Added PartialQuery + Major Improvements (2025-01-20)
NEW FEATURES + BREAKING CHANGES
Added immutable query builder for expert-level composition architecture, plus composable DELETE/UPDATE operations. Also includes several critical improvements based on expert review.
What's New
- PartialQuery class: Immutable query builder (marked
final) - PartialQueryableTrait: Adds
table()method to DatabaseInterface implementations - New DatabaseInterface methods:
quote(mixed $value): string- Quote values for SQL (auto-detects type)table(string $table): PartialQuery- Create query builderdelete(PartialQuery $query): int- Delete rows matching query (requires WHERE)update(PartialQuery $query, string|array $set): int- Update rows matching query
Breaking Changes
- exec() now returns
int(affected rows) instead ofbool - transaction() closure now receives
DatabaseInterfaceas parameter - delete() requires WHERE clause - throws exception if missing
- PartialQuery iterator now streams instead of buffering (removed
fetchAll()) - PartialQuery marked as
final- cannot be extended - count() now respects SELECT columns (uses subquery for DISTINCT etc)
- LIMIT/OFFSET syntax changed to MySQL-compatible
LIMIT offset, count
Primary Value: Architectural Composition
- Reusable fragments: Define base queries once, reuse without side effects
- Safe branching: Branch query logic without mutation or defensive copying
- Encapsulated security: Parameter binding at architectural level
- Expert tool: Not a "beginner ORM" but a composition primitive
Secondary Value: Beginner Safety
- Safe-by-default: SQL injection protection built-in
- IDE autocomplete: Discoverable API via IDE suggestions
Key Features
- Immutable: Each method returns NEW instance (no side effects)
- Composable: Build reusable, non-mutating query fragments
- Safe defaults: 1000 row limit prevents accidental full table scans
- SQL-transparent: Raw SQL always available via
where() - Iterable: Use directly in
foreach - Not an ORM: For complex queries, use
db()->query()directly
Usage
SELECT queries:
// Basic usage
$users = db()->table('users')
->eq('active', 1)
->order('created_at DESC')
->limit(50);
foreach ($users as $user) {
echo $user['name'];
}
// Composable scopes
class User {
public static function spam(): PartialQuery {
return db()->table('users')->eq('status', 'spam');
}
}
$recentSpam = User::spam()
->where('created_at > ?', [date('Y-m-d', strtotime('-7 days'))]);
DELETE/UPDATE with composable queries:
// Delete using scopes
$deleted = db()->delete(User::spam());
// Update with array
db()->update(
db()->table('users')->eq('status', 'inactive'),
['status' => 'archived', 'archived_at' => date('Y-m-d H:i:s')]
);
// Update with SQL expression
db()->update(
db()->table('users')->eq('status', 'active'),
'login_count = login_count + 1'
);
See src/Database/README.md for complete documentation.
PSR-7 Improvements: HTTP Protocol Alignment + Simplifications (2025-01-12)
Multiple PSR-7 improvements: Request/ServerRequest now use request targets (HTTP protocol alignment), PSR-17 factories removed (unnecessary abstraction), and Stream simplified (no serialization).
What Changed
- Request constructor:
new Request($method, $uri, ...)→new Request($method, $requestTarget, ...) - ServerRequest constructor:
new ServerRequest($method, $uri, ..., $queryParams, ...)→new ServerRequest($method, $requestTarget, ..., $queryParams=null, ...) - URI construction:
getUri()now constructs URI dynamically from request target + headers (unless overridden viawithUri()) - Query params:
getQueryParams()now derives from request target by default (unless overridden viawithQueryParams()) - New method:
getQuery()returns query string portion of request target - HTTPS detection: ServerRequest detects scheme from
serverParams['HTTPS']when constructing URI - Removed PSR-17: Deleted
Psr17FactoryandServerRequestCreator- unnecessary abstractions - HttpDispatcher: Now creates ServerRequest directly (SAPI-specific logic belongs in dispatcher)
- New factory:
Request::create($method, $uri)- convenience factory for creating outgoing requests from URIs - Stream::cast() simplified: Removed
$contentTypeparameter and all serialization logic - Stream is purely about wrapping stream resources - Removed helpers: Deleted
create_response(),create_json_response(),emit_response()- just usenew Response()directly
Core Principle
HTTP requests have method, request-target, protocol-version, and headers - not URIs. URIs are constructed on-demand from these components.
Behavior Changes
Request target is source of truth:
// Request target stored directly
$request = new ServerRequest('GET', '/path?foo=bar', '', [], null, []);
$request->getRequestTarget(); // '/path?foo=bar'
$request->getQuery(); // 'foo=bar'
$request->getQueryParams(); // ['foo' => 'bar'] (derived)
$request->getUri()->getQuery(); // 'foo=bar' (constructed)
withQueryParams() does NOT change URI (per PSR-7 spec):
$r2 = $request->withQueryParams(['baz' => 'qux']);
$r2->getRequestTarget(); // '/path?foo=bar' (unchanged!)
$r2->getQueryParams(); // ['baz' => 'qux'] (override)
$r2->getUri()->getQuery(); // 'foo=bar' (unchanged!)
withUri() and withRequestTarget() are independent:
$r3 = $request->withUri(new Uri('http://example.com/other?x=y'));
$r3->getRequestTarget(); // '/path?foo=bar' (unchanged!)
$r3->getUri()->getQuery(); // 'x=y' (URI override)
$r3->getQueryParams(); // ['foo' => 'bar'] (from request target!)
Relative URI when no Host header:
$request = new Request('GET', '/path?query', '', []);
$request->getUri(); // Returns relative URI: '/path?query'
HTTPS detection from server params:
$request = new ServerRequest(
'GET', '/secure', '',
['Host' => 'example.com'],
null,
['HTTPS' => 'on']
);
$request->getUri(); // 'https://example.com/secure'
Migration
Most applications: No changes needed - HttpDispatcher handles request creation internally.
Creating outgoing HTTP requests (HTTP clients, testing):
// Before
$request = new Request('GET', 'http://example.com/path?foo=bar', '');
// After - Use convenience factory
$request = Request::create('GET', 'http://example.com/path?foo=bar');
// Or direct constructor with request target
$request = new Request('GET', '/path?foo=bar', '', ['Host' => 'example.com']);
Creating responses (simple and direct):
// Before
\mini\Http\create_response(200, 'Hello');
\mini\Http\create_json_response(['data' => 'value']);
// After
new Response('Hello', [], 200);
new Response(json_encode(['data' => 'value']), ['Content-Type' => 'application/json'], 200);
Why These Changes?
- HTTP protocol correctness: Requests ARE request targets, not URIs
- PSR-7 compliance:
withQueryParams()must not affect URI (was incorrectly coupled before) - Cleaner separation: URI, query params, and request target have distinct lifecycles
- Performance: No need to construct/store URI object during request creation
- No PSR-17 needed: Mini doesn't need factory abstractions - dispatchers create requests directly
- Environment-specific: HttpDispatcher owns SAPI logic; future FiberHttpDispatcher will own its own creation logic
- Stream responsibility: Stream wraps stream resources - serialization belongs in converters/helpers
Native PSR-7 Implementation (Replaced Nyholm)
Mini now includes its own PSR-7 HTTP message implementation, removing the dependency on nyholm/psr7 and nyholm/psr7-server.
What Changed
- Removed dependencies:
nyholm/psr7andnyholm/psr7-serverno longer required - New classes: All PSR-7 classes now in
mini\Http\Message\namespace - API compatible: Drop-in replacement, no code changes needed for standard PSR-7 usage
- Response constructor signature: Mini's
Responseuses($body, $headers, $statusCode, $reasonPhrase, $protocolVersion)instead of Nyholm's($statusCode, $headers, $body)
New Classes
All classes implement their respective PSR-7 interfaces:
mini\Http\Message\Requestmini\Http\Message\Responsemini\Http\Message\ServerRequestmini\Http\Message\Streammini\Http\Message\Urimini\Http\Message\UploadedFilemini\Http\Message\Psr17Factory(PSR-17 factory)mini\Http\Message\ServerRequestCreator(creates ServerRequest from globals)
Migration
Most applications: No changes needed - Mini's default converters and HttpDispatcher already updated.
If you used Nyholm classes directly:
// Before
use Nyholm\Psr7\Response;
$response = new Response(200, ['Content-Type' => 'text/html'], $body);
// After
use mini\Http\Message\Response;
$response = new Response($body, ['Content-Type' => 'text/html'], 200);
Factory usage (rare - most apps use helper functions):
// Before
use Nyholm\Psr7\Factory\Psr17Factory;
// After
use mini\Http\Message\Psr17Factory;
Why This Change?
- Zero dependencies: Aligns with Mini's zero-dependency architecture
- Extendable: Nyholm's implementation prohibited extending classes
- Control: Full control over PSR-7 behavior and fixes
- Correctness: Nyholm had implementation issues we needed to work around
PSR-7 url() Function with CDN Support
The url() function now returns UriInterface instead of string and includes proper relative path resolution and CDN support.
Changed Signature
// Before
function url($path = '', array $query = []): string
// After
function url(string|UriInterface $path = '', array $query = [], bool $cdn = false): UriInterface
New Behavior
- Returns
UriInterface(PSR-7) instead of string - Properly resolves relative paths (
.,..) - Strips scheme/host from input URLs - always resolves against base URL
- Supports CDN via
$cdnparameter - UriInterface is stringable - templates still work:
<?= url('/path') ?>
New Environment Variable
MINI_CDN_URL- CDN base URL for static assets (optional, defaults tobaseUrl)
Migration
Templates - No changes needed (UriInterface is stringable):
<a href="<?= url('/users') ?>">Users</a>
Type hints - Update if you type-hinted the return value:
// Before
$url = url('/path'); // string
// After
$url = url('/path'); // UriInterface (but still works as string)
CDN usage:
// Static assets via CDN
<link href="<?= url('/css/app.css', cdn: true) ?>" rel="stylesheet">
<img src="<?= url('/images/logo.png', cdn: true) ?>" alt="Logo">
Phase System Introduction
The phase system replaces individual lifecycle hooks with a comprehensive state machine.
Removed Methods
Mini::enterBootstrapPhase()- useMini::$mini->phase->trigger(Phase::Bootstrap)Mini::enterReadyPhase()- useMini::$mini->phase->trigger(Phase::Ready)Mini::enterFailedPhase()- useMini::$mini->phase->trigger(Phase::Failed)Mini::enterShutdownPhase()- useMini::$mini->phase->trigger(Phase::Shutdown)Mini::getCurrentPhase()- useMini::$mini->phase->getCurrentState()Mini::enterRequestContext()- framework now uses phase transitionsMini::exitRequestContext()- framework now uses phase transitions
Removed Hooks
Mini::$onRequestReceived- useMini::$mini->phase->onEnteringState(Phase::Ready, fn() => ...)Mini::$onAfterBootstrap- useMini::$mini->phase->onEnteredState(Phase::Ready, fn() => ...)
Migration Examples
Before:
Mini::$mini->onRequestReceived->listen(function() {
// Authentication logic
});
Mini::$mini->onAfterBootstrap->listen(function() {
// Output buffering setup
});
After:
// Fires when entering Ready phase (before phase change completes)
Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
// Authentication logic
});
// Fires after Ready phase entered (after phase change completes)
Mini::$mini->phase->onEnteredState(Phase::Ready, function() {
// Output buffering setup
});