Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 5.4.12 - 2026-07-15

### What's Changed

* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/426
* fix(queue): add beanstalkd type error by @papac in https://github.com/bowphp/framework/pull/427

**Full Changelog**: https://github.com/bowphp/framework/compare/5.4.11...5.4.12

## 5.4.11 - 2026-07-15

### What's Changed

* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/425
* fix(queue): add beanstalkd type error by @papac in https://github.com/bowphp/framework/pull/424

**Full Changelog**: https://github.com/bowphp/framework/compare/5.4.10...5.4.11

## 5.4.10 - 2026-06-22

### What's Changed
Expand Down Expand Up @@ -362,6 +380,8 @@ Database::transaction(fn() => $user->update(['name' => '']));








Expand Down
22 changes: 11 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,18 @@ Highlights from the latest iterations — already merged into `5.x`. Full detail

| Task | Status | Priority | Notes |
| ---------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------- |
| Fix middleware attribute test (shared state between tests) | ✅ Done | - | `Router::$routes` converted to instance state |
| Fix middleware attribute test (shared state between tests) | ✅ Done | - | `Router::$routes` converted to instance state |
| Fix Pagination tests calling `total()` instead of `totalPages()` | ✅ Done | - | 24 tests fixed |
| Fix Barry model `array` cast returning `stdClass` | ✅ Done | - | `Model::executeDataCasting` + `parseToJson($value, assoc: true)` |
| Fix Validator `nullable\|required` priority | ✅ Done | - | `nullable` no longer short-circuits `required` |
| Fix `EnvTest` singleton pollution between tests | ✅ Done | - | `Env::reset()` added |
| Fix `SchedulerCommand` (`routes/scheduler.php` loading) | ✅ Done | - | `loadSchedulerFile()` updated, tolerates missing Loader |
| Remove dead `Model::$soft_delete` property | ✅ Done | - | Replaced with a fully functional trait |
| Improve `addEnum` / `changeEnum` error messages | ✅ Done | - | Explicitly mention the `size` key |
| Standardize method signatures | ✅ Done | - | PHP 8.1+ nullable types |
| Fix `(double)` → `(float)` cast | ✅ Done | - | `Model.php` |
| Handle `array_key_exists` with null key | ✅ Done | - | `Console.php` |
| Create test directory if missing | ✅ Done | - | `CustomCommand.php` |
| Fix Barry model `array` cast returning `stdClass` | ✅ Done | - | `Model::executeDataCasting` + `parseToJson($value, assoc: true)`|
| Fix Validator `nullable\|required` priority | ✅ Done | - | `nullable` no longer short-circuits `required` |
| Fix `EnvTest` singleton pollution between tests | ✅ Done | - | `Env::reset()` added |
| Fix `SchedulerCommand` (`routes/scheduler.php` loading) | ✅ Done | - | `loadSchedulerFile()` updated, tolerates missing Loader |
| Remove dead `Model::$soft_delete` property | ✅ Done | - | Replaced with a fully functional trait |
| Improve `addEnum` / `changeEnum` error messages | ✅ Done | - | Explicitly mention the `size` key |
| Standardize method signatures | ✅ Done | - | PHP 8.1+ nullable types |
| Fix `(double)` → `(float)` cast | ✅ Done | - | `Model.php` |
| Handle `array_key_exists` with null key | ✅ Done | - | `Console.php` |
| Create test directory if missing | ✅ Done | - | `CustomCommand.php` |

### Documentation

Expand Down
21 changes: 19 additions & 2 deletions src/Application/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ public function run(): bool

$this->router->setPrefix('');

// Raised here rather than from Request::capture(), which runs during
// this class's constructor — too early for the container or the error
// handler to turn it into a response. By now both are up, so a bad
// payload is a rendered 400 instead of an uncaught PHP fatal.
$invalid_json_payload = $this->request->getInvalidJsonPayload();

if (!is_null($invalid_json_payload)) {
throw new BadRequestException(
"The request json payload is invalid: " . $invalid_json_payload,
);
}

$method = $this->request->method();

// We verify the existence of the method of the request in
Expand Down Expand Up @@ -213,12 +225,17 @@ public function send(): bool
* @param int $code
* @return void
*/
private function sendResponse(mixed $response, int $code = 200): void
private function sendResponse(mixed $response, ?int $code = null): void
{
if ($response instanceof ResponseInterface) {
$response->sendContent();
} else {
echo $this->response->send($response, $code);
// Carry the status the response already holds. A controller that
// returned response()->json($data, 404) has set it on this very
// instance, and passing a hardcoded 200 here overwrote it — every
// json() error answered 200 with an error body, so clients could
// not tell success from failure.
echo $this->response->send($response, $code ?? $this->response->getCode());
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/Database/Connection/AbstractConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ abstract class AbstractConnection
*
* @param array $config
*/
public function __construct(array $config)
public function __construct(#[\SensitiveParameter] array $config)
{
$this->config = $config;

Expand Down Expand Up @@ -95,15 +95,15 @@ public function __construct(array $config)
* @param array $config
* @return void
*/
abstract protected function validateConfig(array $config): void;
abstract protected function validateConfig(#[\SensitiveParameter] array $config): void;

/**
* Build a PDO instance from the given configuration.
*
* @param array $config
* @return PDO
*/
abstract protected function makePdo(array $config): PDO;
abstract protected function makePdo(#[\SensitiveParameter] array $config): PDO;

/**
* Build (eagerly) the write connection.
Expand Down
4 changes: 2 additions & 2 deletions src/Database/Connection/Adapters/MysqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class MysqlAdapter extends AbstractConnection
* @param array $config
* @return void
*/
protected function validateConfig(array $config): void
protected function validateConfig(#[\SensitiveParameter] array $config): void
{
// Check the existence of database definition
if (!isset($config['database'])) {
Expand All @@ -44,7 +44,7 @@ protected function validateConfig(array $config): void
* @param array $config
* @return PDO
*/
protected function makePdo(array $config): PDO
protected function makePdo(#[\SensitiveParameter] array $config): PDO
{
// Build of the mysql dsn
if (isset($config['socket']) && !empty($config['socket'])) {
Expand Down
4 changes: 2 additions & 2 deletions src/Database/Connection/Adapters/PostgreSQLAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class PostgreSQLAdapter extends AbstractConnection
* @param array $config
* @return void
*/
protected function validateConfig(array $config): void
protected function validateConfig(#[\SensitiveParameter] array $config): void
{
// Check the existence of database definition
if (!isset($config['database'])) {
Expand All @@ -43,7 +43,7 @@ protected function validateConfig(array $config): void
* @param array $config
* @return PDO
*/
protected function makePdo(array $config): PDO
protected function makePdo(#[\SensitiveParameter] array $config): PDO
{
// Build of the pgsql dsn
if (isset($config['socket']) && !is_null($config['socket']) && !empty($config['socket'])) {
Expand Down
4 changes: 2 additions & 2 deletions src/Database/Connection/Adapters/SqliteAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class SqliteAdapter extends AbstractConnection
* @param array $config
* @return void
*/
protected function validateConfig(array $config): void
protected function validateConfig(#[\SensitiveParameter] array $config): void
{
if (!isset($config['driver'])) {
throw new InvalidArgumentException("Please select the right sqlite driver");
Expand All @@ -40,7 +40,7 @@ protected function validateConfig(array $config): void
* @param array $config
* @return PDO
*/
protected function makePdo(array $config): PDO
protected function makePdo(#[\SensitiveParameter] array $config): PDO
{
// Build the PDO connection
$pdo = new PDO('sqlite:' . $config['database']);
Expand Down
75 changes: 72 additions & 3 deletions src/Database/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ class QueryBuilder implements JsonSerializable
*/
protected ?string $having = null;

/**
* Bound values for the having clause.
*
* Kept separate from where bindings because having placeholders appear
* after where placeholders in the assembled SQL; they are merged in the
* correct positional order when the having clause is appended.
*
* @var array
*/
protected array $having_data_binding = [];

/**
* Order By statement collector
*
Expand Down Expand Up @@ -404,6 +415,34 @@ private static function isComparisonOperator(mixed $comparator): bool
], true);
}

/**
* Guard a column/identifier that is interpolated straight into SQL.
*
* Clauses like order by, group by and having name a column instead of
* binding a value, so the identifier cannot be a placeholder and is
* concatenated into the statement. Restrict it to a plain (optionally
* table-qualified) identifier so it can never carry an injected fragment.
* Raw expressions are intentionally not accepted here.
*
* @param string $identifier
* @param string $clause
* @return string
* @throws QueryBuilderException
*/
private static function assertSafeIdentifier(string $identifier, string $clause): string
{
$trimmed = trim($identifier);

if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/', $trimmed)) {
throw new QueryBuilderException(
"Unsafe identifier passed to {$clause}: [{$identifier}]. "
. "Only a plain or table-qualified column name is allowed."
);
}

return $trimmed;
}

/**
* Formats the select request
*
Expand Down Expand Up @@ -464,6 +503,15 @@ public function toSql(): string

if (!is_null($this->having)) {
$sql .= ' having ' . $this->having;

// having placeholders come after where placeholders in the SQL,
// so appending their values here keeps the positional order.
$this->where_data_binding = array_merge(
$this->where_data_binding,
$this->having_data_binding
);
$this->having_data_binding = [];
$this->having = null;
}
}

Expand Down Expand Up @@ -877,7 +925,7 @@ public function group(string $column)
public function groupBy(string $column): QueryBuilder
{
if (is_null($this->group)) {
$this->group = $column;
$this->group = static::assertSafeIdentifier($column, 'groupBy');
}

return $this;
Expand All @@ -904,10 +952,21 @@ public function having(
$comparator = '=';
}

$column = static::assertSafeIdentifier($column, 'having');

// Bind the value with a placeholder, exactly like where(). A subquery
// is inlined; any scalar is parameterised so it can never be injected.
if ($value instanceof QueryBuilder) {
$indicator = '(' . $value->toSql() . ')';
} else {
$indicator = '?';
$this->having_data_binding[] = $value;
}

if (is_null($this->having)) {
$this->having = $column . ' ' . $comparator . ' ' . $value;
$this->having = $column . ' ' . $comparator . ' ' . $indicator;
} else {
$this->having .= ' ' . $boolean . ' ' . $column . ' ' . $comparator . ' ' . $value;
$this->having .= ' ' . $boolean . ' ' . $column . ' ' . $comparator . ' ' . $indicator;
}

return $this;
Expand All @@ -926,6 +985,8 @@ public function orderBy(string $column, string $type = 'asc'): QueryBuilder
$type = 'asc';
}

$column = static::assertSafeIdentifier($column, 'orderBy');

if (is_null($this->order)) {
$this->order = 'order by ' . $column . ' ' . strtolower($type);
} else {
Expand Down Expand Up @@ -977,6 +1038,14 @@ private function aggregate($aggregate, $column): mixed

if (!is_null($this->having)) {
$sql .= ' having ' . $this->having;

// Keep having values positionally after where values.
$this->where_data_binding = array_merge(
$this->where_data_binding,
$this->having_data_binding
);
$this->having_data_binding = [];
$this->having = null;
}
}

Expand Down
55 changes: 48 additions & 7 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ class Request
*/
private bool $capture = false;

/**
* Why the JSON payload could not be decoded, when it could not be.
*
* @var string|null
*/
private ?string $invalid_json_payload = null;

/**
* Check if file exists
*
Expand Down Expand Up @@ -91,12 +98,30 @@ public function capture()
$this->id = "req_" . sha1(uniqid() . time());

if ($this->getHeader('content-type') == 'application/json') {
try {
$data = json_decode(file_get_contents("php://input"), true, 1024, JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
throw new BadRequestException(
"The request json payload is invalid: " . $e->getMessage(),
);
$raw = (string) file_get_contents("php://input");

// A bodyless request is not a malformed one. Clients routinely send
// "Content-Type: application/json" on a POST/DELETE that carries no
// payload, and json_decode('') throws — a throw this early cannot be
// rendered, because capture() runs from Application::__construct
// before the container binds "response", which BadRequestException
// needs. The client would get a raw PHP fatal instead of a 400.
if (trim($raw) === '') {
$data = [];
} else {
try {
$data = json_decode($raw, true, 1024, JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
// Recorded, not thrown. capture() runs from
// Application::__construct, which is too early for ANY
// exception to be rendered: the container has not bound
// "response" yet and the error handler is installed later
// in the boot, so a throw here reaches the client as a raw
// PHP fatal instead of a 400. Application::run() raises it
// once both are in place.
$this->invalid_json_payload = $e->getMessage();
$data = [];
}
}
} else {
$data = $_POST ?? [];
Expand All @@ -116,6 +141,17 @@ public function capture()
$this->capture = true;
}

/**
* The JSON decoding error for this request, or null when the payload was
* absent or well-formed.
*
* @return string|null
*/
public function getInvalidJsonPayload(): ?string
{
return $this->invalid_json_payload;
}

/**
* Retrieve query variables
*
Expand Down Expand Up @@ -234,7 +270,12 @@ public function get(string $key, mixed $default = null): mixed
{
$value = $this->input[$key] ?? $default;

if (is_callable($value)) {
// Only a callable *default* may be resolved — a closure or an invokable
// object. Never a string or an array: those can come straight from the
// request, and is_callable() is true for the name of any defined
// function, so `?field=phpinfo` would call it and return its result in
// place of the input the caller asked for.
if (is_object($value) && is_callable($value)) {
return $value();
}

Expand Down
Loading
Loading