github phalcon/cphalcon v5.13.0

3 hours ago

5.13.0 (2026-05-18)

Changed

  • Changed Phalcon\Contracts\Support\Collection to declare the expanded method surface (column, each, filter, first, getType, isEmpty, keys, last, map, reduce, replace, sort, values, where) so the contract matches the implementation #17000 [doc]
  • Changed Phalcon\Events\Event to be declared final. The class is a value object holding type, source, data, cancelable, and stopped; no subclasses exist in the cphalcon tree and any future typed-event work would add new sibling classes implementing EventInterface rather than extending Event. Marking it final lets the C extension fold the per-fire getters (getType, getSource, getData, isCancelable, isStopped, isPropagationStopped) into direct dispatch. BC note: any userland class MyEvent extends Event now fails #17006 [doc]
  • Changed Phalcon\Events\Manager::attach() to classify the handler kind once at attach time so the dispatch loop can route via a single branch instead of running instanceof Closure / is_callable / typeof per fire per listener. Four kinds are recognized: 0 = Closure (direct invocation via Zephir's {handler}(args) syntax, no arg-array allocation), 1 = [obj, method] array callable (direct dynamic dispatch handler[0]->{handler[1]}(args), no call_user_func_array overhead), 2 = plain object with method named after the event - the classic Phalcon listener pattern (class name is captured at attach time and stored on the tuple to skip get_class() per fire), 3 = generic callable (string function name, invokable object, [class, staticMethod]) routed through call_user_func_array. The subscriber array-form attach paths ([methodName, priority] and [[methodA, priorityA], [methodB, priorityB]]) now route through insertHandlerEntry directly with kind=1, bypassing the classification cascade since the resulting handler shape is already known. methodExistsCache access in the dispatch loop is tightened to a single isset fast path #17006 [doc]
  • Changed Phalcon\Events\Manager::detach() to drop the eventType key entirely when its queue empties (removing the last listener), so hasListeners() and fire()'s short-circuit tell the truth. Previously an emptied queue left the key in place with an empty array value, causing isset($this->events[$eventType]) to return true with no actual listeners to dispatch to. The matching DetachTest expectations are updated to reflect the new contract #17006 [doc]
  • Changed Phalcon\Events\Manager::detachAll(null) to reset events to [] instead of null. The previous null reset broke isset($this->events[$key]) semantics inside attach() and fire() until the next assignment refilled the property; the empty-array form keeps all access paths consistent #17006 [doc]
  • Changed Phalcon\Events\Manager::fire() and Phalcon\Events\Manager::fireAll() to wrap dispatch in try { ... } catch \Throwable, ex { cleanup; throw ex; }. A throwing listener restores stashed responses (if nested) and decrements fireDepth back to its pre-call value before the exception propagates, so manager state stays clean for the next fire - important for long-lived managers (workers, daemons) where a single dirty teardown would poison every subsequent fire #17006 [doc]
  • Changed Phalcon\Events\Manager::fire() to parse the eventType only once per unique string and cache the result ([typePrefix, eventName]) in an internal eventNameCache keyed by the original string. Repeated fires of the same event name (db:beforeQuery × N per request, model lifecycle events on hot save paths) collapse to a single hash lookup after warm-up. The cache never needs invalidation because the parse is deterministic for a given input string. fire() also short-circuits before allocating the Event instance when no listeners match either the type queue or the fully-qualified queue - in production most fires have zero matching listeners (model lifecycle events with no user-attached behavior, DB events without a tracer), so the avoided allocation is significant in hot paths #17006 [doc]
  • Changed Phalcon\Events\Manager::fire(), Phalcon\Events\Manager::attach(), and Phalcon\Events\Manager::fireQueue() to be declared final. The class itself stays open so genuine subclasses that only add new methods continue to work, but the dispatch hot path is locked to enable C-level direct dispatch on the three methods that run per-event. The remaining public surface (addSubscriber, removeSubscriber, detach, detachAll, getListeners, getResponses, getSubscribers, hasListeners, isCollecting, isValidHandler, collectResponses, enablePriorities, arePrioritiesEnabled, isStrict, setStrict, fireAll, clearSubscribers) is left non-final so decorator-style subclasses that wrap these less-hot methods can still override them. BC note: subclasses that override fire, attach, or fireQueue now fail #17006 [doc]
  • Changed Phalcon\Events\Manager::fireQueue() to be a thin BC-preserving wrapper around a new private Phalcon\Events\Manager::dispatch() helper. The public signature fireQueue(array $queue, EventInterface $event) is unchanged; the framework's own fire() path bypasses fireQueue and calls dispatch() directly with hoisted arguments (eventName, source, data, cancelable, collect) so the second dispatch leg of a two-queue fire() (type queue + fully-qualified queue) does not re-extract identical values from the event. The dispatch path also applies a single-handler fast path: when the queue has exactly one listener (very common in unit tests and lightly-instrumented production), the foreach plus per-iteration cancelable/isStopped check is skipped and dispatch goes straight through the type-branch #17006 [doc]
  • Changed Phalcon\Events\Manager dispatch return-value contract to last non-null wins. Previously every listener return overwrote the running status, so a chain of ("value", null) ended with fire() returning null and silently losing the earlier real value. The new contract only updates status when the listener return is non-null - the last meaningful return survives. The stop() determinism rule overrides last-non-null: when a listener calls $event->stop() (and cancelable=true), that listener's return value is what fire() returns - even if null - because the caller asked for the stopping listener's verdict explicitly. Returning false from a listener does not short-circuit the queue; callers wanting to stop downstream listeners must call $event->stop(). Consumers like Phalcon\Mvc\Dispatcher that interpret a false return from fire() as a cancel signal are unaffected because that check happens in their own dispatch logic, not in the events manager #17006 [doc]
  • Changed Phalcon\Events\Manager listener storage from SplPriorityQueue to a sorted array of [handler, type, priority] tuples (with an additional className element on type=2 tuples). The SplPriorityQueue::EXTR_BOTH clone-per-fire and O(n) setExtractFlags() rebuild on detach are eliminated; the "empty heap" warnings produced by SplPriorityQueue on never-fired event types disappear as a side effect. Insert order under the same priority is preserved (FIFO). When enablePriorities is off (the default), insertHandlerEntry short-circuits to a plain append - the sorted-insert loop only runs when priorities are explicitly enabled. When it does run, the insert uses array_splice instead of a per-element rebuild #17006 [doc]
  • Changed Phalcon\Html\Escaper into a façade over five per-context escapers (Phalcon\Html\Escaper\HtmlEscaper, AttributeEscaper, CssEscaper, JsEscaper, UrlEscaper); each is reachable via getXxxEscaper()/setXxxEscaper() so individual contexts can be swapped without subclassing the façade. The legacy setEncoding(), setFlags(), and setDoubleEncode() setters fan out to all sub-escapers so existing code keeps working #16971 [doc]
  • Changed Phalcon\Html\Helper\AbstractSeries::__toString() to ksort() its store before rendering so entries are emitted in position order rather than registration order. #16971 [doc]
  • Changed Phalcon\Html\Helper\Input\Checkbox and Phalcon\Html\Helper\Input\Radio to extend a new shared Phalcon\Html\Helper\Input\AbstractChecked; Radio no longer extends Checkbox. Two paths now render checked="checked": the unconditional opt-ins ["checked" => "checked"] (case-insensitive) and ["checked" => true], and a value-match path comparing the supplied checked attribute against the input's value (== by default for mixed int/string round-tripping, === under strict(true)) #16971 [doc]
  • Changed Phalcon\Html\Helper\Style::add() and Phalcon\Html\Helper\Script::add() now accept an optional int $position = -1 argument that is routed through the new protected pushOrPlace() helper (negative pushes onto the next auto-increment slot, non-negative places at that key, advancing past occupied slots) #16971 [doc]
  • Changed Phalcon\Html\TagFactory to no longer extend Phalcon\Factory\AbstractFactory; the registration pipeline now accepts class-strings, closures/callables, or [className, [depKey, ...]] / [className, [depKey, ...], [extraArg, ...]] tuples, and helpers are lazily cached per name in a separate instances map so set() overrides correctly invalidate the previous instance. has() now reports against the recipe map instead of the resolved-instance map #16971 [doc]
  • Changed Phalcon\Mvc\Model\Resultset\Complex::current() LEFT-JOIN hydration to be controlled by the new orm.resultset_empty_left_join_model ini setting (default true). When the flag is true (default) a LEFT JOIN that matches no row hydrates an empty Model instance whose every column is null - preserving the pre-5.13 behavior so applications upgrading from 5.9.x do not need code changes. Setting the flag to false (via php.ini, .htaccess, or Phalcon\Support\Settings::set('orm.resultset_empty_left_join_model', false)) restores the 5.13.0 contract introduced by #16239, where the unmatched slot is plainly null. The new key is wired into Phalcon\Support\Settings::get() / set() alongside the other orm.* toggles #16960 [doc]
  • Changed Phalcon\Mvc\Url\UrlInterface::get() signature to match the implementation: the previously undeclared var baseUri = null fourth parameter (added to Phalcon\Mvc\Url::get() in 2015 but never propagated to the interface) and the new bool replaceArgs = false fifth parameter are now part of the contract #16986 [doc]
  • Changed Phalcon\Support\Collection::__construct() to accept two additional opt-in parameters - bool $strictNull = false and ?string $type = null. When strictNull is true, get() returns stored null values verbatim instead of substituting the default (previous behavior was unconditional default-substitution); when type is set, every set()/init() runs through the new validateType() guard, which maps the scalar tokens int/string/bool/float/array/object to their is_* checks and treats anything else as a class/interface name compared with instanceof, throwing InvalidArgumentException on mismatch #17000 [doc]
  • Changed Phalcon\Support\Collection::__serialize() to return a structured array ["data", "insensitive", "strictNull", "type"] so a serialize/unserialize round-trip restores the full configuration (previously only data survived). __unserialize() accepts both the new structured format and the legacy flat-array format, so existing serialized blobs keep deserializing without intervention #17000 [doc]
  • Changed Phalcon\Support\Collection::getKeys() and Phalcon\Support\Collection::getValues() to delegate to the new keys() / values() methods and marked the get*-prefixed pair @deprecated; behavior is unchanged but new code should call keys() / values() instead #17000 [doc]

Added

  • Added CHECK-constraint support via new Phalcon\Db\Check value object and Phalcon\Db\CheckInterface. Check takes a constraint name (string; empty string means an unnamed constraint, in which case the dialect omits the CONSTRAINT <name> prefix) and a definition array containing the required expression key (the boolean SQL predicate). Phalcon\Db\Dialect\Mysql, Phalcon\Db\Dialect\Postgresql, and Phalcon\Db\Dialect\Sqlite all recognize definition["checks"] (array of CheckInterface) inside createTable() and emit an inline [CONSTRAINT "<name>"] CHECK (<expr>) line alongside the column/index/reference lines. New dialect methods addCheck() and dropCheck() emit the equivalent ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) and ALTER TABLE ... DROP CHECK|CONSTRAINT ... SQL for MySQL 8.0.16+ and PostgreSQL; SQLite throws (CHECK constraints can only be declared at CREATE TABLE time in SQLite, the same limitation that already applies to FK constraints). New adapter methods Phalcon\Db\Adapter\AbstractAdapter::addCheck() and Phalcon\Db\Adapter\AbstractAdapter::dropCheck() provide the symmetric one-call ergonomics already available for addForeignKey() / dropForeignKey(). The new dialect and adapter methods are declared as commented-out @todo v7 stubs on Phalcon\Db\DialectInterface and Phalcon\Db\Adapter\AdapterInterface to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added MySQL 8.0+ INVISIBLE index support to Phalcon\Db\Index. The constructor's second parameter is now backward-compatibly overloaded: passing a plain list of column names continues to work (legacy positional form), while passing an associative array containing a columns key activates the new definition-array form (["columns" => [...], "type" => "...", "invisible" => true]). The third positional type argument is honored only when the second argument is the legacy list form; in definition-array mode type comes from the array. Index gains a matching isInvisible(): bool accessor and throws a Phalcon\Db\Exception if the definition-array path is taken but columns is not itself an array. Phalcon\Db\Dialect\Mysql::addIndex and Phalcon\Db\Dialect\Mysql::createTable emit a trailing INVISIBLE keyword for invisible indexes. Phalcon\Db\Adapter\Pdo\Mysql::describeIndexes reverse-engineers the flag from the MySQL 8.0+ Visible column of SHOW INDEXES (absent on 5.7, which defaults to visible). PostgreSQL and SQLite have no equivalent and their dialects ignore the flag. The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Index to avoid breaking third-party implementors during the v5 line. This definition-array hook is the path that will be reused by upcoming items #8 (descending indexes), #9 (partial indexes), and #10 (functional indexes) so the constructor stays tidy #14719 [doc]
  • Added MySQL 8.0.23+ INVISIBLE column support to Phalcon\Db\Column. A new boolean definition-array key invisible (default false) is parsed by the constructor; a matching isInvisible(): bool accessor reports the state at runtime. Phalcon\Db\Dialect\Mysql::addColumn, Phalcon\Db\Dialect\Mysql::createTable, and Phalcon\Db\Dialect\Mysql::modifyColumn emit INVISIBLE after the NOT NULL/NULL clause when the flag is set. PostgreSQL and SQLite have no equivalent and their dialects ignore the flag. Phalcon\Db\Adapter\Pdo\Mysql::describeColumns reverse-engineers the flag from the EXTRA column of information_schema.COLUMNS (already in the result set since item #1's switch from SHOW FULL COLUMNS) - substring-matched so the flag is still detected when MySQL concatenates it with other extras like INVISIBLE STORED GENERATED. The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Column to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added PostgreSQL CREATE INDEX CONCURRENTLY support via a new concurrently definition-array key on Phalcon\Db\Index (default false). Phalcon\Db\Index::isConcurrent(): bool exposes the flag at runtime. Phalcon\Db\Dialect\Postgresql::addIndex now emits CONCURRENTLY between the INDEX keyword and the index name when the flag is set (CREATE INDEX CONCURRENTLY "idx_email" ON "schema"."table" (...)), so the index can be built without taking the strong lock that normally blocks writers. MySQL and SQLite have no equivalent concept and their dialects ignore the flag. No reverse engineering is meaningful (the option is creation-time only - once built, the index is indistinguishable from one built non-concurrently). The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Index to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added PostgreSQL materialized-view support to the Db dialect and adapter layers. Three new methods land on Phalcon\Db\Dialect: createMaterializedView(string $viewName, array $definition, string $schemaName = null): string (definition takes a required sql key, same shape as createView), dropMaterializedView(string $viewName, string $schemaName = null, bool $ifExists = true): string, and refreshMaterializedView(string $viewName, string $schemaName = null, bool $concurrent = false): string (passing $concurrent = true emits REFRESH MATERIALIZED VIEW CONCURRENTLY ... for non-blocking refresh - requires a unique index on the view). Phalcon\Db\Dialect\Postgresql overrides all three to emit the correct SQL; the base implementations throw Phalcon\Db\Exception, which is inherited unchanged by Phalcon\Db\Dialect\Mysql and Phalcon\Db\Dialect\Sqlite (neither engine has a materialized-view concept). Phalcon\Db\Adapter\AbstractAdapter gains three matching createMaterializedView() / dropMaterializedView() / refreshMaterializedView() wrappers that execute the dialect-built SQL and return bool. #14719 [doc]
  • Added PostgreSQL-specific column types and array-column support to Phalcon\Db\Column and Phalcon\Db\Dialect\Postgresql. Ten new Column::TYPE_* constants are introduced: TYPE_BYTEA (30), TYPE_INET (31), TYPE_CIDR (32), TYPE_MACADDR (33), TYPE_INT4RANGE (34), TYPE_INT8RANGE (35), TYPE_NUMRANGE (36), TYPE_TSRANGE (37), TYPE_TSTZRANGE (38), TYPE_DATERANGE (39). Phalcon\Db\Dialect\Postgresql::getColumnDefinition recognizes the new types and emits the matching keywords (BYTEA, INET, CIDR, MACADDR, INT4RANGE, INT8RANGE, NUMRANGE, TSRANGE, TSTZRANGE, DATERANGE). MySQL and SQLite dialects fall through their existing default branches for these constants - users targeting those engines should pick a portable base type instead. Additionally, a new boolean definition-array key array and a Phalcon\Db\Column::isArray(): bool accessor expose array-column intent; when isArray() is true, the PostgreSQL dialect appends [] to the type (INTEGER[], TEXT[], INET[], etc.). MySQL and SQLite ignore the flag. Phalcon\Db\Adapter\Pdo\Postgresql::describeColumns reverse-engineers the new types by matching the data_type column from information_schema.columns and sets array when data_type reports ARRAY or contains []. The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Column to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added FOR SHARE shared-lock emission to Phalcon\Db\Dialect\Postgresql::sharedLock(); it previously returned the original query unchanged (silent no-op), so callers had no way to express PostgreSQL's row-level shared lock through the cphalcon dialect API. The method now appends " FOR SHARE" and also accepts the optional string $modifier = "" second argument introduced by item #3, so callers can request FOR SHARE NOWAIT / FOR SHARE SKIP LOCKED via the Phalcon\Contracts\Db\Dialect::LOCK_NOWAIT / LOCK_SKIP_LOCKED constants. The signature change is propagated to Phalcon\Contracts\Db\Dialect::sharedLock, Phalcon\Contracts\Db\Adapter\Adapter::sharedLock, and the SQLite and MySQL impls - SQLite remains a no-op regardless of the modifier (no row-level locking), and MySQL still emits its legacy LOCK IN SHARE MODE and silently ignores any modifier (the legacy syntax does not support NOWAIT / SKIP LOCKED; users on MySQL 8.0+ who need those modifiers can use forUpdate() instead). Phalcon\Db\Adapter\AbstractAdapter::sharedLock passes the modifier through to the dialect #14719 [doc]
  • Added NOWAIT / SKIP LOCKED row-lock modifiers to forUpdate(). The dialect and adapter forUpdate() methods now accept an optional second string $modifier = "" argument; pass one of the new contract constants Phalcon\Contracts\Db\Dialect::LOCK_NONE (default), Phalcon\Contracts\Db\Dialect::LOCK_NOWAIT, or Phalcon\Contracts\Db\Dialect::LOCK_SKIP_LOCKED to emit SELECT … FOR UPDATE, SELECT … FOR UPDATE NOWAIT, or SELECT … FOR UPDATE SKIP LOCKED respectively. Recognized by MySQL 8.0+ and PostgreSQL 9.5+; SQLite has no row-level locking and silently ignores the modifier. Signature change is propagated to Phalcon\Contracts\Db\Dialect::forUpdate, Phalcon\Contracts\Db\Adapter\Adapter::forUpdate, Phalcon\Db\Dialect::forUpdate (base), Phalcon\Db\Dialect\Sqlite::forUpdate (override remains a no-op), and Phalcon\Db\Adapter\AbstractAdapter::forUpdate (pass-through). Existing single-argument call sites are unaffected - the second parameter defaults to "" #14719 [doc]
  • Added ON CONFLICT (...) DO UPDATE SET ... upsert support to the Db dialect and adapter layers. New SQL-transformer method Phalcon\Db\Dialect::onConflictUpdate(string $sqlQuery, array $conflictColumns, array $updateColumns): string appends an upsert clause to the supplied INSERT statement using the SQL-standard form recognized by PostgreSQL 9.5+ and SQLite 3.24+: INSERT INTO ... ON CONFLICT ("col") DO UPDATE SET "other" = excluded."other". The base implementation in Phalcon\Db\Dialect::onConflictUpdate provides the standard emission (inherited by Phalcon\Db\Dialect\Postgresql and Phalcon\Db\Dialect\Sqlite); Phalcon\Db\Dialect\Mysql::onConflictUpdate overrides to throw because MySQL's INSERT ... ON DUPLICATE KEY UPDATE shape is incompatible (deferred to parser item #23). Throws Phalcon\Db\Exception when either the conflictColumns or updateColumns array is empty. Phalcon\Db\Adapter\AbstractAdapter::onConflictUpdate provides the symmetric one-call passthrough. The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Dialect and Phalcon\Contracts\Db\Adapter\Adapter to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added Phalcon\Contracts\Db namespace housing the canonical contracts for the Db layer: Phalcon\Contracts\Db\Check, Phalcon\Contracts\Db\Column, Phalcon\Contracts\Db\Dialect, Phalcon\Contracts\Db\Index, Phalcon\Contracts\Db\Reference, Phalcon\Contracts\Db\Result, and Phalcon\Contracts\Db\Adapter\Adapter. The legacy interfaces (Phalcon\Db\CheckInterface, Phalcon\Db\ColumnInterface, Phalcon\Db\DialectInterface, Phalcon\Db\IndexInterface, Phalcon\Db\ReferenceInterface, Phalcon\Db\ResultInterface, Phalcon\Db\Adapter\AdapterInterface) are kept as thin extensions of their contract counterparts, marked @deprecated (matching the Phalcon\Support\Collection\CollectionInterface migration pattern), so existing implementors and typehints continue to work in the v5 line. The @todo v7 commented-out stubs for the generated-column API (item #1) and CHECK-constraint API (item #2) live in the canonical contract files, which is where they will be uncommented when v7 ships #14719 [doc]
  • Added Phalcon\Contracts\Events\Stoppable - a Phalcon-owned mirror of PSR-14's StoppableEventInterface with the single isPropagationStopped(): bool method. Phalcon\Events\Event implements it and routes the call through the same internal stopped flag as isStopped(), so existing $event->stop() callers automatically expose the PSR-14-shaped accessor without changing their code. Keeps PSR types out of the extension while letting a future phalcon/events-psr14-bridge package map Phalcon ↔ PSR-14 in PHP land #17006 [doc]
  • Added Phalcon\Contracts\Events\Subscriber - a subscriber contract requiring a single static getSubscribedEvents(): array method. Supported shapes per entry: ['event:name' => 'methodName'] (plain string), ['event:name' => ['methodName', priority]] (method with priority), or ['event:name' => [['methodA', priorityA], ['methodB', priorityB]]] (multiple listeners per event). Replaces the magic-method-only routing as the primary discoverability mechanism while keeping that path intact for BC #17006 [doc]
  • Added Phalcon\Contracts\Forms\Schema interface defining a single load(): array method for objects that supply a normalized list of form-element definitions #16996 [doc]
  • Added Phalcon\Contracts\Mvc\Model\Relation\CacheKeyProvider with a single getUniqueKey(): string method; when a model implements this interface and a relation is marked reusable => true, the Model Manager uses the return value of getUniqueKey() as the cache key instead of the object-identity-based unique_key() builtin, allowing different PHP object instances that represent the same database row to share the same reusable record cache entry #16993 [doc]
  • Added Phalcon\Di\Di::hasShared(string $name): bool and Phalcon\Di\Di::removeShared(string $name): void (also declared on Phalcon\Di\DiInterface) to operate on the shared-instance cache independently of the service-definition registry. hasShared() reports whether getShared() has already materialized an instance for the given name (in contrast to has(), which reports on the definition registry). removeShared() drops the cached instance - both from Di::$sharedInstances and from the Service object's internal sharedInstance field - without removing the service definition, so the next getShared() call rebuilds a fresh instance. Use case: fork-based multi-process workers that need to discard the parent's inherited resource handle (DB connection, etc.) without re-registering the service. Both methods are alias-aware #13440 [doc]
  • Added Phalcon\Encryption\Security::setAutoRefresh(bool) and Phalcon\Encryption\Security::refreshToken() to make CSRF-token rotation opt-out. When setAutoRefresh(false) is called, getToken() and getTokenKey() reuse the existing session values instead of generating a new pair on every call, eliminating the per-request session write that backend session stores billed per-write (DynamoDB, Redis-with-billing, etc.) would otherwise incur on read-only requests. refreshToken() provides an explicit, atomic rotation of both the token and the token key (used after login or any other state change where rotation is appropriate). The default autoRefresh = true preserves the existing one-time-use behavior; opt-in only #14413 [doc]
  • Added Phalcon\Events\Manager::addSubscriber(), Phalcon\Events\Manager::removeSubscriber(), Phalcon\Events\Manager::getSubscribers(), and Phalcon\Events\Manager::clearSubscribers() to register and unregister Subscriber instances. The subscriber's getSubscribedEvents() map is parsed once at attach time and each entry is routed through the regular listener pipeline; the result is cached per class name in a new internal subscriberEventsCache so repeated add/remove of the same subscriber class doesn't re-invoke the static method. removeSubscriber() is idempotent; clearSubscribers() iterates a snapshot of registered subscribers so the underlying property can be mutated safely during the walk. Subscribers are keyed by spl_object_id() so the same instance cannot be double-registered #17006 [doc]
  • Added Phalcon\Events\Manager::fireAll(string $eventType, object $source, mixed $data = null, bool $cancelable = true): array returning every listener's return value as an indexed array. Independent of collectResponses(); the caller's collected state on $this->responses is stashed on entry and restored on exit so a fireAll() call from inside a collect-mode fire() does not pollute the outer accumulator #17006 [doc]
  • Added Phalcon\Events\Manager::halt(), Phalcon\Events\Manager::resume(), and Phalcon\Events\Manager::isHalted(): bool - a manager-level kill switch separate from $event->stop(). stop() only stops the current dispatch chain; halt() survives across fire() calls and makes every subsequent fire() / fireAll() / fireQueue() call return immediately (null or []) without dispatching, until resume() clears the flag. Use case: a security check or initialization-failure path that needs to abort all downstream event activity for the rest of the request #17006 [doc]
  • Added Phalcon\Events\Manager::setStopOnFalse(bool) / Phalcon\Events\Manager::isStopOnFalse(): bool - an opt-in per-event short-circuit. When setStopOnFalse(true) has been called and the fire's cancelable flag is on, a listener returning literal false stops the dispatch loop for that event and pins the fire() return as false; later listeners cannot overwrite the cancel. Default off, preserving the pre-5.13 last-wins return-value semantics so existing codebases are unaffected. Independent of halt() and $event->stop() - only governs how the dispatch loop reacts to a literal false listener return #17006 [doc]
  • Added Phalcon\Events\Manager::setStrict(bool) / Phalcon\Events\Manager::isStrict(): bool to enable strict mode. When strict mode is on, fire() and fireAll() throw Phalcon\Events\Exception on dispatch of an event with zero matching listeners - useful in development for catching typos in event names. Default off; opt-in only #17006 [doc]
  • Added Phalcon\Forms\Element\CheckGroup and Phalcon\Forms\Element\RadioGroup form elements that render multiple <input type="checkbox"> / <input type="radio"> inputs from a single registered form-element entry. CheckGroup auto-suffixes the name with [] when not already present so PHP collects checked values into an array on submission; both elements bind cleanly because they live under a single key #16996 [doc]
  • Added Phalcon\Forms\Form::load(Schema $schema, FormsLocator $locator) that consumes element definitions from any Schema source and resolves their type strings through the supplied locator, allowing custom element types via FormsLocator::setElement() #16996 [doc]
  • Added Phalcon\Forms\FormsLocator, a closure-based registry for named forms (get/has/set) and element-type factories (getElement/hasElement/setElement); entity-less form lookups are lazily cached, while entity-aware lookups always rebuild. The default element registry seeds factories for check, checkgroup, date, email, file, hidden, numeric, password, radio, radiogroup, select, submit, text, and textarea #16996 [doc]
  • Added Phalcon\Forms\Loader\ArrayLoader, Phalcon\Forms\Loader\JsonLoader, and Phalcon\Forms\Loader\YamlLoader - three Schema implementations that feed Form::load() from a PHP array, a JSON string/file, or a YAML string/file (pecl/yaml) respectively #16996 [doc]
  • Added Phalcon\Forms\Manager::loadForm(string $name, Schema $schema, ?object $entity = null) and Phalcon\Forms\Manager::getLocator(); loadForm() builds a form from a Schema, registers it in the manager, and also registers a factory in the locator so subsequent entity-aware retrievals via FormsLocator::get($name, $entity) rebuild fresh instances. Manager::__construct(FormsLocator $locator = null) accepts a nullable locator and instantiates a default FormsLocator when omitted (BC: pre-existing new Manager() calls continue to work) #16996 [doc]
  • Added Phalcon\Html\Escaper\AbstractEscaper, Phalcon\Html\Escaper\AttributeEscaper, Phalcon\Html\Escaper\CssEscaper, Phalcon\Html\Escaper\HtmlEscaper, Phalcon\Html\Escaper\JsEscaper, and Phalcon\Html\Escaper\UrlEscaper as the per-context building blocks behind Phalcon\Html\Escaper #16971 [doc]
  • Added Phalcon\Html\Helper\Input\AbstractGroup, Phalcon\Html\Helper\Input\CheckboxGroup, and Phalcon\Html\Helper\Input\RadioGroup; the base resolves option entries (scalar label or [label, ...attrs] map) into <input> + <label> pairs sharing a single HTML name, with auto-generated id per value ({name}_{value}) and per-item attribute pass-through. CheckboxGroup matches against an array (or scalar coerced into one) for checked; RadioGroup matches against a single scalar. Registered in Phalcon\Html\TagFactory as inputCheckboxGroup and inputRadioGroup #16996 [doc]
  • Added Phalcon\Html\Helper\Input\Generic and Phalcon\Html\Helper\Input\AbstractChecked; Generic accepts the HTML5 type via the constructor (with setType() to change it after construction), letting TagFactory register a single class for all type-string-only inputs and differentiate them through the recipe map #16971 [doc]
  • Added Phalcon\Html\Helper\Input\Select::placeholder(string $text) to inject a <option value="" disabled selected>$text</option> first entry, and Phalcon\Html\Helper\Input\Select::strict(bool $flag = true) to opt the option/selected comparison from the new loose default into strict (===) matching #16971 [doc]
  • Added Phalcon\Html\Helper\Script::beginInternal() and endInternal(array $attributes = [], int $pos = -1) to capture inline JavaScript via output buffering and append it to the asset stack as a <script>...</script> block #16971 [doc]
  • Added Phalcon\Html\Helper\Tag (open tag) and Phalcon\Html\Helper\VoidTag (self-closing tag) as escape hatches for arbitrary tag names without a dedicated helper; available via TagFactory as tag and voidTag #16971 [doc]
  • Added Phalcon\Mvc\Router::getMethodRoutes(): array - returns the internal HTTP-method index (routes bucketed by method string, unconstrained routes under "*"). handle() now builds a candidate list from only the matching-method bucket plus the unconstrained bucket and iterates that subset in reverse, eliminating the O(n) per-route HTTP-method check that previously ran against every registered route on each request #17015 [doc]
  • Added Phalcon\Mvc\Router::loadFromConfig(array|ConfigInterface $config): RouterInterface - initializes the router from a data structure, accepting the top-level keys defaultRoutes, removeExtraSlashes, defaults, notFound, routes, and groups. Each routes entry supports method (one of connect, delete, get, head, options, patch, post, purge, put, trace; omitted = any method), pattern, paths, name, and hostname. Each groups entry supports prefix, hostname, paths, and a nested routes array, then is mounted via mount() #15050 [doc]
  • Added Phalcon\Mvc\Router\RouterFactory with load(array|ConfigInterface $config): RouterInterface and newInstance(bool $defaultRoutes = true): RouterInterface, mirroring the idiomatic ConfigFactory/LoggerFactory pattern; load() honors the optional top-level defaultRoutes key (defaulting to true) and delegates route assembly to Router::loadFromConfig() #15050 [doc]
  • Added Phalcon\Paginator\Adapter\QueryBuilderCursor - a keyset (cursor-based) pagination adapter that accepts a QueryBuilder, a limit, and a cursorColumn (the column used as the cursor key, typically a primary key). Each paginate() call fetches limit + 1 rows using cursorColumn > :cursor: to skip already-seen rows; the extra row is used only to detect whether a next page exists and is never returned. getNext() returns the last visible row's cursor value, or 0 when no further page exists. setCursor(int|null $cursor) advances or resets the position. getTotalItems() and getLast() return 0 by design - no COUNT query is issued. Registered in PaginatorFactory as "queryBuilderCursor" #14754 [doc]
  • Added Phalcon\Support\Collection:column(string $propertyOrMethod): array (lift a single property/method off every item, keyed by the original collection key) #17000 [doc]
  • Added Phalcon\Support\Collection:each(callable $callback) (run the callback for side effects and return $this for chaining) #17000 [doc]
  • Added Phalcon\Support\Collection:filter(callable $callback) (new collection of items where the callback returns truthy) #17000 [doc]
  • Added Phalcon\Support\Collection:first() / last() (head/tail value or null on empty) #17000 [doc]
  • Added Phalcon\Support\Collection:isEmpty(): bool, getType(): string|null (the type guard configured at construction) #17000 [doc]
  • Added Phalcon\Support\Collection:keys(bool $insensitive = true) and values() (the non-get-prefixed names, with getKeys/getValues retained as deprecated wrappers) #17000 [doc]
  • Added Phalcon\Support\Collection:map(callable $callback) (new collection of transformed values, keys preserved) #17000 [doc]
  • Added Phalcon\Support\Collection:reduce(callable $callback, mixed $initial = null): mixed (callback signature ($accumulator, $value, $key)) #17000 [doc]
  • Added Phalcon\Support\Collection:replace(array $data): void (clear then init in one call) #17000 [doc]
  • Added Phalcon\Support\Collection:sort(?callable $callback = null, int $order = SORT_ASC) (uasort when a callback is supplied, otherwise asort/arsort keyed by $order) #17000 [doc]
  • Added Phalcon\Support\Collection:where(string $propertyOrMethod, mixed $value) (strict-equality filter via extractValue) #17000 [doc]
  • Added Phalcon\Time\Clock\Exception with the invalidModifier() named constructor; FrozenClock::adjust() throws this exception uniformly across PHP versions when the modifier string cannot be parsed (catching DateMalformedStringException on PHP 8.3+ and trapping the E_WARNING plus false return on earlier versions, leaving the clock state untouched on failure) #16965 [doc]
  • Added Phalcon\Time\Clock namespace with ClockInterface, SystemClock, and FrozenClock to wrap clock functionality for the application; SystemClock returns the current time as a DateTimeImmutable in a configurable timezone (with fromUTC() and fromSystemTimezone() named constructors), while FrozenClock returns a fixed instant for deterministic testing and exposes set() and adjust() to move the clock in place (returning $this for fluent chaining) #16965 [doc]
  • Added RETURNING clause support to the Db dialect and adapter layers. New SQL-transformer method Phalcon\Db\Dialect::returning(string $sqlQuery, array $columns): string appends a RETURNING clause to an INSERT / UPDATE / DELETE statement; pass ["*"] for RETURNING * or a list of column identifiers for RETURNING "col1", "col2". Phalcon\Db\Dialect\Postgresql::returning and Phalcon\Db\Dialect\Sqlite::returning provide the emission (SQLite requires 3.35+). The base implementation in Phalcon\Db\Dialect::returning throws Phalcon\Db\Exception, which is inherited unchanged by Phalcon\Db\Dialect\Mysql since MySQL has no RETURNING construct. An empty columns array throws on PgSQL and SQLite. Phalcon\Db\Adapter\AbstractAdapter::returning provides the symmetric one-call passthrough so users can do $connection->query($connection->returning($sql, ["id"])). The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Dialect and Phalcon\Contracts\Db\Adapter\Adapter to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added Raw factory variants aRaw, buttonRaw, elementRaw, labelRaw, olRaw, and ulRaw to Phalcon\Html\TagFactory, each backed by a tuple recipe that pins raw = true on the constructor of the underlying helper #16971 [doc]
  • Added an opt-in bool $replaceArgs = false fifth parameter to Phalcon\Mvc\Url::get(); when true and the supplied $uri already contains a query string, the existing query is parsed via parse_str() and merged under array_merge($existing, (array) $args) so user-supplied keys override colliding ones (e.g. $url->get('http://example.com?page=1', ['page' => 5], null, null, true) now returns http://example.com?page=5 instead of http://example.com?page=1&page=5). The default (flag omitted) preserves the legacy append-with-& behavior to keep existing callers working #16986 [doc]
  • Added back the generate_pecl workflow job and package.xml, restoring publication of a phalcon-pecl.tgz artifact (also attached to GitHub releases) for a few more versions to give downstream users time to migrate to PIE
  • Added functional/expression index support to Phalcon\Db\Index. Entries inside the columns array can now be Phalcon\Db\RawValue instances; string entries continue to be treated as column identifiers and escaped. The base Phalcon\Db\Dialect::getIndexColumnList() helper detects RawValue entries and per-dialect renders them - MySQL and PostgreSQL wrap each expression in its own parentheses (KEY idx ((LOWER(col))) and CREATE INDEX ON t ((lower(col))) respectively), while SQLite emits the expression verbatim (its grammar accepts expr directly as an indexed-column). The helper gains an optional bool $wrapExpressions = true flag - defaults are tuned per dialect at the call site so users do not need to think about it. Expressions compose with the descending direction (#8) and partial-index predicate (#9) without any additional API surface. Reverse-engineering of expressions is deferred (PostgreSQL via pg_get_expr(pg_index.indexprs, ...), SQLite via sqlite_master.sql parsing - same conservative cutoff used in item #1). No new accessor method is needed - Index::getColumns() continues to return the entries (now of mixed string / RawValue type) #14719 [doc]
  • Added generated/computed column support to Phalcon\Db\Column via two new definition-array keys: generated (the SQL expression as a string; null keeps the column non-generated) and generationStored (bool, falseVIRTUAL, trueSTORED; PostgreSQL ignores the flag and always emits STORED). Three new public methods report the state at runtime - getGenerationExpression(): string | null, isGenerated(): bool, isGenerationStored(): bool. The class enforces that a generated column cannot also declare default or autoIncrement. All three dialects (Mysql, Postgresql, Sqlite) emit GENERATED ALWAYS AS (<expr>) VIRTUAL\|STORED from addColumn(), createTable(), and (where supported) modifyColumn(), and skip the DEFAULT / AUTO_INCREMENT / AUTOINCREMENT clauses for generated columns. Reverse-engineering through describeColumns() is also wired up: MySQL switches from SHOW FULL COLUMNS to an equivalent information_schema.COLUMNS query that additionally returns GENERATION_EXPRESSION; PostgreSQL extends its information_schema.columns query with is_generated and generation_expression; SQLite switches from PRAGMA table_info to PRAGMA table_xinfo so the hidden flag (2 → VIRTUAL, 3 → STORED) can populate isGenerated() / isGenerationStored(). SQLite cannot expose the generation expression through any pragma, so getGenerationExpression() round-trips as an empty string for SQLite-introspected generated columns (documented limitation). The new methods are declared as commented @todo v7 stubs on Phalcon\Db\ColumnInterface to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added hostname-aware URL generation to Phalcon\Mvc\Url::get(): when a named route carries a setHostname() restriction the returned URL is now protocol-relative (//hostname/path) so it works transparently under both HTTP and HTTPS #17007 [doc]
  • Added new Contracts (aka Interfaces) for Phalcon\Encryption\Security namely Phalcon\Contracts\Encryption\Security\Security, CryptoUtils, CsrfProtection, and PasswordSecurity and tied them to the component #16991 [doc]
  • Added partial-index support on Phalcon\Db\Index via a new where definition-array key (string). Phalcon\Db\Index::getWhere(): string exposes the configured predicate (empty string when none). Phalcon\Db\Dialect\Postgresql::addIndex and Phalcon\Db\Dialect\Sqlite::addIndex append WHERE <expr> to the emitted CREATE INDEX statement. MySQL has no partial-index feature and its dialect ignores the value. Reverse-engineering of the predicate is deferred for both PostgreSQL (requires pg_get_expr(pg_index.indpred, pg_index.indrelid)) and SQLite (requires sqlite_master.sql parsing) - same conservative cutoff used for SQLite generation expressions in item #1. Throws Phalcon\Db\Exception if the definition-array where key is supplied with a non-string value. The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Index #14719 [doc]
  • Added per-column sort direction (ASC / DESC) support on Phalcon\Db\Index via a new directions definition-array key. The array is parallel to columns; trailing positions absent from directions default to ASC at emission time. Phalcon\Db\Index::getDirections(): array exposes the configured list (empty array means "no per-column direction declared" - preserves the legacy plain (col1, col2) rendering exactly). A new protected Phalcon\Db\Dialect::getIndexColumnList(IndexInterface) helper centralizes the direction-aware emission and is now used by Phalcon\Db\Dialect\Mysql::addIndex / createTable, Phalcon\Db\Dialect\Postgresql::addIndex / createTable, and Phalcon\Db\Dialect\Sqlite::addIndex / createTable. Phalcon\Db\Adapter\Pdo\Mysql::describeIndexes reverse-engineers directions from the Collation column of SHOW INDEXES (A = ASC, D = DESC, NULL = ASC); the resulting Index only carries a non-empty directions array when at least one column is DESC, so existing introspection workflows that don't expect direction metadata see no diff. PostgreSQL and SQLite reverse-engineering of directions is deferred (pg_index.indoption and sqlite_master.sql parsing respectively - same conservative cutoff used for SQLite generation expressions in item #1). The new method is declared as a commented @todo v7 stub on Phalcon\Contracts\Db\Index to avoid breaking third-party implementors during the v5 line #14719 [doc]
  • Added per-option HTML attribute support to the Phalcon\Html\Helper\Input\Select data-provider path: Phalcon\Html\Helper\Input\Select\SelectDataInterface now also exposes getAttributes() returning [optionValue => [attrName => stringValue, ...]]; ArrayData accepts a second constructor argument with the resolved per-value attribute map, and ResultsetData accepts a third attributesMap argument (htmlAttr => string|callable) whose closures receive the current row. Resolution happens once in the providers (with false/null results dropped); rendering continues to flow through the existing AbstractHelper attribute pipeline #16983 [doc]
  • Added precompiled ARM64 binaries for Linux (ubuntu-22.04-arm runner) and macOS (macos-14, Apple Silicon) to the release artifacts. The build_extension CI matrix now produces phalcon-php<ver>-<ts>-ubuntu-gcc-arm64.zip and phalcon-php<ver>-<ts>-macos-clang-arm64.zip alongside the existing x64 builds, and the macOS composite action installs pcre2/re2c via Homebrew, points CPPFLAGS/LDFLAGS at the keg-only pcre2 headers, and enables the extension in PHP's PHP_CONFIG_FILE_SCAN_DIR so php --ri phalcon succeeds #16553
  • Added spatial / geometry column-type support to Phalcon\Db\Column and the MySQL and PostgreSQL dialects. Eight new Column::TYPE_* constants land: TYPE_GEOMETRY (40), TYPE_POINT (41), TYPE_LINESTRING (42), TYPE_POLYGON (43), TYPE_MULTIPOINT (44), TYPE_MULTILINESTRING (45), TYPE_MULTIPOLYGON (46), TYPE_GEOMETRYCOLLECTION (47). Phalcon\Db\Dialect\Mysql::getColumnDefinition and Phalcon\Db\Dialect\Postgresql::getColumnDefinition emit the matching keywords (MySQL recognizes them natively from 5.7; PostgreSQL needs PostGIS installed, which interprets the keywords). SQLite has no native spatial type and its dialect leaves these constants in the default branch. Phalcon\Db\Adapter\Pdo\Mysql::describeColumns reverse-engineers the new types by starts_with-matching the column type - order in the switch was chosen so the longer multi-* / geometrycollection variants are matched before their shorter prefixes (linestring before polygon, etc.). PostgreSQL reverse-engineering for spatial types is deferred because information_schema.data_type does not consistently expose PostGIS type names without joining pg_type - users introspecting PostGIS schemas should query metadata directly until then. Caveat - read-side WKB hydration is not part of this change. A POINT selected directly with SELECT location FROM items still returns raw WKB bytes (cphalcon issues #14769 and #13670); the workaround is to project ST_AsText(location) / ST_AsBinary(location) / ST_AsGeoJSON(location) server-side. The DDL/describe support shipped here is the schema-level prerequisite for any future result-set hydration helper #14719 [doc]
  • Added support for arbitrary SQL expressions in DDL DEFAULT clauses by recognizing Phalcon\Db\RawValue instances passed as definition["default"]. Previously each dialect quoted any non-numeric, non-CURRENT_TIMESTAMP, non-NULL default as a string literal - preventing legitimate expression defaults like MySQL 8.0.13+ DEFAULT (UUID()), PostgreSQL DEFAULT gen_random_uuid() / DEFAULT nextval('seq'), or SQLite 3.31+ DEFAULT strftime('%s','now'). Phalcon\Db\Dialect\Mysql::addColumn / createTable / modifyColumn, Phalcon\Db\Dialect\Postgresql::castDefault (used by all three of its DDL paths), and Phalcon\Db\Dialect\Sqlite::addColumn / createTable now detect RawValue defaults and emit DEFAULT <raw> verbatim. Plain-scalar and CURRENT_TIMESTAMP / NULL keyword defaults continue to take the existing whitelist path unchanged. Column::hasDefault() already treats a RawValue as a non-null default, so isAutoIncrement() semantics and the generated-column "no default allowed" guard from item #1 remain correct #14719 [doc]
  • Added the canonical Events contracts under the Phalcon\Contracts\Events namespace: Phalcon\Contracts\Events\Event (replaces Phalcon\Events\EventInterface), Phalcon\Contracts\Events\EventsAware (replaces Phalcon\Events\EventsAwareInterface), and Phalcon\Contracts\Events\Manager (replaces Phalcon\Events\ManagerInterface). The legacy Phalcon\Events\*Interface types are kept as thin extensions of their canonical counterparts, marked @deprecated, matching the migration pattern used for Phalcon\Support\Collection\CollectionInterface. Existing implementors and typehints continue to work in the v5 line #17006 [doc]
  • Enabled dropColumn() on the SQLite dialect to emit ALTER TABLE ... DROP COLUMN ... instead of throwing unconditionally - SQLite 3.35+ natively supports ALTER TABLE DROP COLUMN, and pre-empting it at the cphalcon dialect level prevented modern users from using the feature. On engines older than 3.35 the server itself rejects the statement at execution time, with a clearer "near 'DROP': syntax error" message than the previous cphalcon-side throw. Phalcon\Db\Adapter\Pdo\Sqlite::dropColumn already passes through the dialect output via AbstractAdapter, so users now get a one-call $connection->dropColumn(...) on SQLite #14719 [doc]

Fixed

  • Fixed .ci/release-notes.sh failing intermittently with grep: write error: Broken pipe once CHANGELOG-5.0.md grew past the pipe-buffer threshold; the grep ... | head -n N pipelines (with set -o pipefail active) gave grep SIGPIPE when head closed the FD before grep finished writing, aborting the step. Replaced both pipelines with single-pass awk programs that exit on the matching line, eliminating the broken-pipe race entirely while keeping the original "extract from the latest ## heading down to one line above the next # heading" behavior
  • Fixed Phalcon\Annotations\AnnotationsFactory::newInstance("memory") segfaulting the PHP process #16962 [doc]
  • Fixed Phalcon\Cli\Console::handle() and Phalcon\Mvc\Application::handle() not propagating the configured defaultModule to the dispatcher when the router returned no module name; both methods resolved a local moduleName (router value, falling back to $this->defaultModule) for the module-loading path but then called $dispatcher->setModuleName($router->getModuleName()), discarding the fallback. As a result, dispatcher events such as dispatch:beforeDispatchLoop saw an empty module name even when setDefaultModule() had been called. Both call sites now pass the resolved moduleName to the dispatcher #17013 [doc]
  • Fixed Phalcon\Db\Dialect::getSqlExpression() not propagating the outer bindCounts map into the recursive select() call rendered for nested sub-SELECTs, so an array placeholder ({name:array}) inside a sub-SELECT kept the parse-time times count baked into the cached PHQL intermediate; a second execution of the same outer PHQL with the same bind name but a different bind-array size fell back to the stale count and PDO raised "Invalid parameter number: number of bound variables does not match number of tokens" (workaround was Phalcon\Mvc\Model\Query::clean() between calls). The select branch now seeds the nested definition's bindCounts from the outer bindCounts on a local copy of the nested intermediate so the runtime placeholder-count override fires at every nesting level without mutating the cached intermediate #17004 [doc]
  • Fixed Phalcon\Events\Manager::fireQueue() segfaulting when invoking a Closure listener whose declared parameter count is less than the three arguments the manager passes ($event, $source, $data). The previous handler->__invoke(...) form routed through PHP's strict C-level method-call path which sets up a 3-slot frame that the closure's own 2-slot (or fewer) frame cannot accept, producing undefined behavior and a process crash. The dispatch path now uses Zephir's {handler}(args) callable-invocation syntax, which routes through zend_call_function and handles closure arity mismatch correctly #17006 [doc]
  • Fixed Phalcon\Forms\Form::bind() leaving the entity property untouched when a Check element's key was absent from submitted data (the browser behavior for an unchecked checkbox), so a previously-true field could never be reset by re-submitting the form. Phalcon\Forms\Element\Check gains opt-in setUncheckedValue() / getUncheckedValue() / hasUncheckedValue(); when an unchecked value has been registered and the element's data key is missing from the bind payload, bind() injects the registered value into $data before the main loop so it flows through whitelist, filters, and entity setters identically to a submitted value. Checks without an explicit setUncheckedValue() preserve the previous behavior (entity untouched) #16982 [doc]
  • Fixed Phalcon\Forms\Form::bind() silently dropping data for Radio elements registered under distinct form-element identifiers but sharing the same HTML name attribute (e.g. new Radio('r0', ['name' => 'banned']), new Radio('r1', ['name' => 'banned']) bound from ['banned' => 'yes']); the bind loop only looked up $this->elements[$key] and skipped the value when no element matched, so the entity property was never set. The lookup now falls back to scanning registered elements for one whose HTML name attribute matches the data key #15957 [doc]
  • Fixed Phalcon\Html\Helper\Input\AbstractInput::__invoke() always assigning an id attribute equal to the element name, producing invalid markup like id="tags[]" when the name contained brackets (array names or indexed names like roles[0]). The auto-id is now skipped when the name contains [, matching the behavior of the corresponding helper in the PHP-side phalcon/phalcon library
  • Fixed Phalcon\Mvc\Model::__set() not clearing the cached related record when a belongsTo relation alias is assigned null; calling a getter before setting the relation to null caused preSaveRelatedRecords() to overwrite the FK back to its previous value on save #16611 [doc]
  • Fixed Phalcon\Mvc\Model::assign() and Phalcon\Mvc\Model::writeAttribute() silently dropping values for columns whose name maps to a reserved internal setter (source, schema, dirtyState, connectionService, readConnectionService, writeConnectionService, eventsManager, transaction, snapshotData, oldSnapshotData); Phalcon\Mvc\Model::possibleSetter() returned true for these reserved names while skipping both the setter call and the property assignment, so the value never landed on the model. It now returns false for reserved setters, letting the caller fall through to direct property assignment while still preventing the reserved internal setter from being hijacked by the column #16617 [doc]
  • Fixed Phalcon\Mvc\Model::cloneResultMap() and Phalcon\Mvc\Model::possibleSetter() throwing TypeError during hydration when a model setter has a strict type hint (e.g. ?array) and the raw database value is incompatible; the ORM now catches TypeError and falls back to direct property assignment #16956 [doc]
  • Fixed Phalcon\Mvc\Model::doLowInsert() throwing Unable to insert into <table> without data when saving a model whose only column is an auto-increment primary key; on dialects where useExplicitIdValue() is false (MySQL, SQLite) the identity branch produced an empty values array. The identity column is now added with the connection's default identity value when the resulting values array would otherwise be empty #156 [doc]
  • Fixed Phalcon\Mvc\Model::find() return type regression introduced in 5.7.0 (PR #16578) that removed the : ResultsetInterface runtime declaration while adding @template T generic docblocks for IDE autocompletion. Subclass overrides that narrowed the return type to : ResultsetInterface and call sites that assigned the result to a typed property (private ResultsetInterface $robots = Robots::find(...)) broke on upgrade from 5.6.x. The runtime return type is restored, and the docblock claim was tightened from T[]|\Phalcon\Mvc\Model\Resultset<int, T> to \Phalcon\Mvc\Model\Resultset<int, T> so it is a strict subtype of the runtime declaration - Psalm previously raised MismatchingDocblockReturnType on the generated ide-stubs because T[] resolves to array<int, T>, which is not a ResultsetInterface (the T[] half of the union was always false; find() never returns a plain PHP array, even under HYDRATE_ARRAYS). Modern IDEs and static analyzers continue to resolve element-type T through the surviving generic annotation. findFirst() is unchanged - it has always returned var | null because it can legitimately return a ModelInterface, a Row, or null depending on the projection #16633 [doc]
  • Fixed Phalcon\Mvc\Model::findFirst() docblock claim that diluted the generic return type. The annotation was @return T|\Phalcon\Mvc\ModelInterface|\Phalcon\Mvc\Model\Row|null (introduced by PR #16578), but since @template T of static and every Model subclass implements ModelInterface, the ModelInterface union member is a supertype of T and Psalm/PHPStan collapsed the union to its widest member - losing T and forcing user code that called child-class-specific methods on findFirst()'s result to assert instanceof self first. The redundant ModelInterface member is removed; the annotation is now @return T|\Phalcon\Mvc\Model\Row|null, preserving T for static analyzers while keeping Row (returned when the query projects scalar columns) and null (no match) honest #16661 [doc]
  • Fixed Phalcon\Mvc\Model::preSaveRelatedRecords() skipping doSave() for belongsTo parents whose dirtyState was DIRTY_STATE_PERSISTENT; modifications made to an existing parent (loaded via findFirst()) and then attached to a new child were silently dropped on save, while only the FK was written. The dirtyState short-circuit (originally added to break circular hasMany ⇄ belongsTo recursion) is removed and recursion is now prevented by the visited collection already enforced inside doSave() #16222 [doc]
  • Fixed Phalcon\Mvc\Model\Query::executeUpdate() and Phalcon\Mvc\Model::doLowUpdate() for PHQL UPDATE ... SET <expr> expressions with placeholders (e.g. col = col + :inc:): named placeholders embedded in expression SQL are now resolved before creating RawValue to avoid PDO "mixed named and positional parameters", and dynamic-update comparisons now always treat RawValue assignments as changed so updates are not skipped when the current numeric value is 0 #16976 [doc]
  • Fixed Phalcon\Mvc\Model\Query::parse() returning a cached intermediate representation with the model's original schema/source baked into the tables slot, so subsequent find()/findFirst()/findBy*() calls for the same PHQL string kept emitting SQL pointing at the original schema after the model changed its schema at runtime (e.g. via a wrapper calling setSchema()); the cache-hit branch now refreshes the tables entries from the live model->getSchema()/getSource() before returning, preserving the parse-cache benefit while keeping the rendered FROM clause in sync with current model state #17020 [doc]
  • Fixed Phalcon\Mvc\Router::handle() leaving unmatched optional named parameters (e.g. {id:(/[0-9]+)?}{slug:(/.+)?}) in the resulting params with their raw regex group positions (id => 1, slug => 2) instead of unsetting them; the unset branch was nested under else of typeof converters === "array", but Route::$converters defaults to [] so the branch was unreachable. The check is now flattened so an unmatched positional parameter is removed when no per-part converter exists #16559 [doc]
  • Fixed Phalcon\Mvc\View::getActiveRenderPath() returning only the first candidate path as a string when a single viewsDir was configured with multiple registered render engines and the view was not found; the method now collapses the internal activeRenderPaths array to a string only when it contains exactly one element, returning the full array of candidate paths in all other cases #16614 [doc]
  • Fixed Phalcon\Mvc\View\Engine\Volt\Compiler rejecting single-quoted Volt string literals containing an escaped single quote (e.g. {{ 'Let\'s Encrypt' }}) with a downstream PHP T_STRING parse error; the Volt scanner already preserves the user-written \' verbatim in expr["value"], so the compiler's str_replace("'", "\\'", expr["value"]) step double-escaped it into Let\\'s Encrypt, producing PHP source 'Let\\'s Encrypt' that PHP read as Let\ followed by an unexpected identifier. Dropped the redundant str_replace; the scanner's regex ((['] ([\\][']|[\\].|...) ['])) guarantees expr["value"] is already valid PHP single-quoted string content #17002 [doc]
  • Fixed Phalcon\Support\Collection::get() returning the wrapping object's mangled property table when called with $cast = 'array' on a stored object (e.g. a nested Phalcon\Config\Config, which Config::setData() wraps around every array value). The previous implementation called settype($value, 'array') unconditionally, which on an object exposes PHP's null-prefixed internal protected-property names ("\0*\0data", "\0*\0lowerKeys", etc.) instead of the intended array form, so $config->get('outKey', [], 'array')['inKey'] raised Notice: Undefined index. The cast branch now special-cases 'array' for objects exposing a toArray() method and delegates to that method; scalar casts (int, bool, float, string, null, object) and 'array' on a plain stdClass (no toArray()) are unchanged #17005 [doc]
  • Fixed Phalcon\Tag\Select::optionsFromArray() not escaping option label text, allowing XSS injection via malicious values; labels are now escaped with escapeHtml() and option values with escapeHtmlAttr() via the escaper service, consistent with optionsFromResultset() #16660 [doc]
  • Fixed nested Phalcon\Events\Manager::fire() calls clobbering the outer caller's collected $this->responses state. Previously the inner fire would reset $this->responses = [] and append its own listener returns there, so after the nested fire returned the outer caller's getResponses() saw the inner fire's results instead of its own. fire() now tracks re-entrancy depth via a new internal fireDepth counter and, on a nested call with collect=true, stashes the outer's responses on entry and restores them on exit so the outer accumulator is preserved across the inner dispatch. fireAll() applies the same stash-and-restore pattern unconditionally so it never pollutes outer collected state regardless of the manager's collectResponses() setting #17006 [doc]

Removed

  • Removed calls to version_compare that led to pre PHP 8.0 code #16966
  • Removed the per-type input helpers Phalcon\Html\Helper\Input\Color, Date, DateTime, DateTimeLocal, Email, File, Hidden, Image, Input, Month, Numeric, Password, Range, Search, Submit, Tel, Text, Time, Url, and Week; their TagFactory service names (inputColor, inputDate, ...) now resolve to Phalcon\Html\Helper\Input\Generic with the appropriate type baked into the recipe, so callers using the factory keep working unchanged #16971 [doc]

Don't miss a new cphalcon release

NewReleases is sending notifications on new releases.