Skip to content

Support Laravel 12 - #207

Open
LukeTowers wants to merge 198 commits into
developfrom
wip/1.3
Open

Support Laravel 12#207
LukeTowers wants to merge 198 commits into
developfrom
wip/1.3

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Feb 25, 2025

Copy link
Copy Markdown
Member

Replaces #173, continuing the work done by @mjauvin @bennothommo & @wverhoogt

Summary by CodeRabbit

  • New Features

    • Added PHP 8.2+ support and modern framework capabilities.
    • Added MariaDB connectivity and improved database support across major platforms.
    • Added configurable authentication password attributes and fluent authentication setup.
    • Added optional pagination totals and improved expression-based searching.
    • Added broader translation path and locale fallback handling.
    • Added customizable application path generation.
  • Bug Fixes

    • Improved schema column-change handling across database platforms.
    • Prevented invalid file records from producing malformed results.
    • Improved form value handling and month formatting.

Realised that this would be too much a BC break given that these methods
are intended to be extended upon.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Halcyon/Builder.php (1)

161-175: Add null-safety check to lists() method at line 172.

The review comment is correct. With getFresh() returning array|null (line 282), passing the result directly to the Collection constructor without a null check creates a potential TypeError. The get() method properly handles this with $results ?: [] (line 331), but lists() lacks this protection. Apply the suggested defensive pattern:

-        $collection = new Collection($results);
+        $collection = new Collection($results ?: []);

This ensures consistency with the get() method and prevents errors when getFresh() returns null.

src/Database/Connections/MySqlConnection.php (1)

70-79: Bug: floats bound as integers (loss of precision) and null/bool not handled.

Binding floats with PDO::PARAM_INT truncates decimals. Also, null and boolean values should be explicitly handled with their respective PDO parameter types.

Apply this diff:

     public function bindValues($statement, $bindings)
     {
         foreach ($bindings as $key => $value) {
             $statement->bindValue(
                 is_string($key) ? $key : $key + 1,
                 $value,
-                is_int($value) || is_float($value) ? PDO::PARAM_INT : PDO::PARAM_STR
+                is_int($value) ? PDO::PARAM_INT
+                    : (is_bool($value) ? PDO::PARAM_BOOL
+                    : (is_null($value) ? PDO::PARAM_NULL
+                    : PDO::PARAM_STR))
             );
         }
     }
♻️ Duplicate comments (22)
src/Foundation/Http/Middleware/CheckForMaintenanceMode.php (1)

26-45: Re-throw non-503 HttpExceptions to preserve error handling.

Non-503 HttpExceptions are caught but not re-thrown, causing them to be silently swallowed and the method to return null. This breaks proper HTTP error handling for status codes like 404, 401, 403, etc.

Apply this diff to re-throw non-503 exceptions:

             }
+
+            throw $ex;
         }
     }
 }
src/Halcyon/MemoryCacheManager.php (1)

10-12: Verify that MemoryRepository constructor was added to prevent fatal error.

A past review flagged that MemoryRepository lacks a constructor accepting the $config parameter. The relevant code snippet confirms that MemoryRepository still has no __construct method defined. Without a proper constructor, calling new MemoryRepository($store, $config) will likely cause an ArgumentCountError if the parent Repository constructor only accepts one parameter.

Run the following script to verify whether:

  1. MemoryRepository now has a constructor accepting $config
  2. The parent Repository class constructor signature supports this change
#!/bin/bash
# Verify MemoryRepository and parent Repository constructor signatures

echo "=== Checking MemoryRepository constructor ==="
rg -n -A 10 "class MemoryRepository" src/Halcyon/MemoryRepository.php | rg -A 8 "__construct"

echo -e "\n=== Checking parent Repository class constructor ==="
# Repository is from Illuminate\Cache, check if Storm has a custom one or uses Laravel's
fd -t f "Repository.php" | xargs rg -l "namespace.*Cache" | head -5 | while read file; do
  echo "File: $file"
  rg -n -A 5 "class Repository" "$file" | rg -B 1 -A 5 "__construct"
done
src/Network/Http.php (1)

277-277: Technical improvement, but security concern persists.

Changing false to 0 is technically more correct, as CURLOPT_SSL_VERIFYHOST expects an integer value (0, 1, or 2). However, the underlying security issue flagged in the previous review remains: SSL host verification is still disabled by default, leaving all HTTP requests vulnerable to MITM attacks unless verifySSL() is explicitly called.

composer.json (1)

60-69: Pin the dev-branch dependency to a specific commit.

The dms/phpunit-arraysubset-asserts package uses an unstable dev branch that could break without notice. Since no stable release supports PHPUnit 11, pin the dependency to a specific commit hash.

Apply this pattern:

-        "dms/phpunit-arraysubset-asserts": "dev-add-phpunit-11-support",
+        "dms/phpunit-arraysubset-asserts": "dev-add-phpunit-11-support#abc1234",

Replace abc1234 with the actual commit hash from the fork.

src/Auth/Models/User.php (1)

316-325: Align setter with dynamic password column.

If $authPasswordName is overridden, this mutator still touches the hard-coded password key, so resets and assignments leave the configured column untouched. Please drive the unset/assignment through getAuthPasswordName() to keep the model consistent with the new configurability.

-        if ($this->exists && empty($value)) {
-            unset($this->attributes['password']);
-        } else {
-            $this->attributes['password'] = $value;
+        $passwordField = $this->getAuthPasswordName();
+
+        if ($this->exists && empty($value)) {
+            unset($this->attributes[$passwordField]);
+        } else {
+            $this->attributes[$passwordField] = $value;
src/Database/Relations/Concerns/DeferOneOrMany.php (1)

127-133: Don't coerce the deferred key expression to string.

Switching to ->getValue() forces the builder to treat the CAST(...) fragment as an identifier, so platforms like MySQL wrap it in backticks and break the deferred BelongsToMany / MorphToMany queries. Returning the raw Expression preserves the original behaviour.

-    protected function getWithDeferredQualifiedKeyName(): string
-    {
-        return $this->parent->getConnection()->raw(DbDongle::cast(
-            DbDongle::getTablePrefix() . $this->related->getQualifiedKeyName(),
-            'TEXT'
-        ))->getValue($this->parent->getGrammar());
-    }
+    protected function getWithDeferredQualifiedKeyName(): \Illuminate\Database\Query\Expression
+    {
+        return $this->parent->getConnection()->raw(DbDongle::cast(
+            DbDongle::getTablePrefix() . $this->related->getQualifiedKeyName(),
+            'TEXT'
+        ));
+    }
src/Translation/FileLoader.php (2)

21-45: Carry the reducer accumulator forward in loadNamespaceOverrides.

Returning $lines whenever a path misses wipes out overrides gathered from earlier paths, so multi-path namespace overrides silently fail. Seed the reducer with $lines and merge into $output each iteration instead of resetting it.

-        return collect($this->paths)
-            ->reduce(function ($output, $path) use ($lines, $locale, $group, $namespace) {
+        return collect($this->paths)
+            ->reduce(function ($output, $path) use ($locale, $group, $namespace) {
                 $winterNamespace = str_replace('.', '/', $namespace);
@@
-                return $lines;
-            }, []);
+                return $output;
+            }, $lines);

69-73: Merge dash-locale overrides instead of replacing the accumulator.

The dash-locale fallback currently returns the raw file contents, throwing away everything accumulated from prior paths. Merge the dash-locale array into $output so previously loaded segments survive.

-                if ($dashFile !== $file && $this->files->exists($dashFile)) {
-                    return $this->files->getRequire($dashFile);
+                if ($dashFile !== $file && $this->files->exists($dashFile)) {
+                    return array_replace_recursive($output, $this->files->getRequire($dashFile));
                 }
src/Database/PDO/Concerns/ConnectsToDatabase.php (1)

24-26: Fix the error message to reference Winter instead of Laravel.

The exception message incorrectly mentions "Laravel" but this is Winter CMS.

Apply this diff:

-        if (! isset($params['pdo']) || ! $params['pdo'] instanceof PDO) {
-            throw new InvalidArgumentException('Laravel requires the "pdo" property to be set and be a PDO instance.');
-        }
+        if (! isset($params['pdo']) || ! $params['pdo'] instanceof PDO) {
+            throw new InvalidArgumentException('Winter requires the "pdo" property to be set and be a PDO instance.');
+        }
src/Database/Model.php (1)

25-25: Confirm HasAttributes trait provides required methods.

The Model now uses Concerns\HasAttributes trait, but past review comments indicate that hasAttribute() method is missing from the trait. This method is called in getAttribute() at Line 1070 and will cause a fatal error if not defined.

tests/GrammarTestCase.php (1)

37-40: Fix incorrect mock invocation syntax.

Line 39 attempts to invoke the mock as a callable with ($this->connection)(), but Mockery mocks are not directly callable. This will cause a runtime error when getConnection() is called.

Apply this diff:

     public function getConnection()
     {
-        return ($this->connection)();
+        return $this->connection;
     }
src/Database/Schema/Grammars/PostgresGrammar.php (1)

63-70: Don't strip all single quotes from defaults; only trim surrounding quotes.

The preg_replace('#\'#', '', $value) call removes every single quote inside the string, which will corrupt values like "O'Connor" → "OConnor". Only remove wrapping quotes to avoid corrupting literals.

Apply this diff:

     public function getDefaultValue($value)
     {
-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
-        }
-
-        return parent::getDefaultValue($value);
+        if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
+            // Trim only surrounding single quotes to avoid double-quoting while preserving inner apostrophes
+            $value = substr($value, 1, -1);
+        }
+        return parent::getDefaultValue($value);
     }
src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php (2)

36-36: Bug: Passing ColumnDefinition to wrap() will cause failure.

The wrap() method expects a string identifier, not a ColumnDefinition object. This will produce invalid SQL or fatal errors.

Apply this fix:

             $sql = sprintf(
                 '%s %s%s %s',
                 is_null($column->renameTo) ? 'modify' : 'change',
-                $this->wrap($column),
+                $this->wrap($column->name),
                 is_null($column->renameTo) ? '' : ' '.$this->wrap($column->renameTo),
                 $this->getType($column)
             );

59-66: Bug: Regex strips all quotes, corrupting values like "O'Reilly".

The current preg_replace('#\'#', '', $value) removes every single quote, including those inside the string. This corrupts default values containing apostrophes.

Apply this fix to only strip wrapping quotes:

     public function getDefaultValue($value)
     {
-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
+        if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
+            $value = substr($value, 1, -1);
         }
 
         return parent::getDefaultValue($value);
     }
tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php (1)

51-51: Bug: Invalid Postgres SQL syntax in test expectation.

Postgres does not support ... null as a clause. The correct syntax is drop not null to make a column nullable. The expectation also has an extra space.

Apply this fix:

-        $this->assertSame('alter column "name"  null', $parts[1]);
+        $this->assertSame('alter column "name" drop not null', $parts[1]);
tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php (1)

3-3: Fix incorrect namespace declaration.

The namespace is missing the Winter\Storm\ prefix, which will cause autoloading and test discovery failures. This is inconsistent with other test classes in the codebase.

Apply this diff:

-namespace Tests\Database\Schema\Grammars;
+namespace Winter\Storm\Tests\Database\Schema\Grammars;
src/Database/Schema/Grammars/SQLiteGrammar.php (1)

103-105: Bug: str_starts_with arguments reversed; sqlite_ indexes not filtered.

The arguments to str_starts_with are reversed, so internal sqlite_* indexes will never be filtered out. This can cause errors when recreating indexes.

Apply this diff:

-        $indexes = collect($indexes)->reject(fn ($index) => str_starts_with('sqlite_', $index->index))->map(
+        $indexes = collect($indexes)->reject(fn ($index) => str_starts_with($index->index, 'sqlite_'))->map(
             fn ($index) => $this->{'compile'.ucfirst($index->name)}($blueprint, $index)
         )->all();
src/Database/PDO/Connection.php (4)

42-53: Don't rely on assert; handle PDO::exec() false explicitly.

PDO::exec() can return false without throwing an exception. The current code uses assert() which is insufficient, and returning false violates the int return type.

Apply this diff:

     public function exec(string $statement): int
     {
         try {
             $result = $this->connection->exec($statement);
-
-            \assert($result !== false);
-
-            return $result;
+            if ($result === false) {
+                $errorInfo = $this->connection->errorInfo();
+                throw Exception::new(new PDOException($errorInfo[2] ?? 'PDO::exec() failed', 0));
+            }
+            return (int) $result;
         } catch (PDOException $exception) {
             throw Exception::new($exception);
         }
     }

61-70: Guard against PDO::prepare() returning false.

PDO::prepare() returns false on failure without throwing an exception. The current code can trigger a TypeError in createStatement().

Apply this diff:

     public function prepare(string $sql): StatementInterface
     {
         try {
-            return $this->createStatement(
-                $this->connection->prepare($sql)
-            );
+            $pdoStmt = $this->connection->prepare($sql);
+            if ($pdoStmt === false) {
+                $errorInfo = $this->connection->errorInfo();
+                throw Exception::new(new PDOException($errorInfo[2] ?? 'PDO::prepare() failed', 0));
+            }
+            return $this->createStatement($pdoStmt);
         } catch (PDOException $exception) {
             throw Exception::new($exception);
         }
     }

78-89: Guard against PDO::query() returning false.

PDO::query() can return false on failure. Avoid relying on assert() for production error handling.

Apply this diff:

     public function query(string $sql): ResultInterface
     {
         try {
             $stmt = $this->connection->query($sql);
-
-            \assert($stmt instanceof PDOStatement);
-
-            return new Result($stmt);
+            if ($stmt === false) {
+                $errorInfo = $this->connection->errorInfo();
+                throw Exception::new(new PDOException($errorInfo[2] ?? 'PDO::query() failed', 0));
+            }
+            return new Result($stmt);
         } catch (PDOException $exception) {
             throw Exception::new($exception);
         }
     }

158-161: quote() may return false; enforce string return.

PDO::quote() returns string|false. Returning false violates the declared return type.

Apply this diff:

     public function quote($input, $type = ParameterType::STRING)
     {
-        return $this->connection->quote($input, $type);
+        $quoted = $this->connection->quote($input, $type);
+        if ($quoted === false) {
+            $errorInfo = $this->connection->errorInfo();
+            throw Exception::new(new PDOException($errorInfo[2] ?? 'PDO::quote() failed', 0));
+        }
+        return $quoted;
     }
src/Database/Connections/MariaDbConnection.php (1)

70-79: Bug: floats bound as integers (loss of precision) and null/bool not handled.

Binding floats with PDO::PARAM_INT truncates decimals. Null and boolean values should also be explicitly handled with their respective PDO parameter types.

Apply this diff:

     public function bindValues($statement, $bindings)
     {
         foreach ($bindings as $key => $value) {
             $statement->bindValue(
                 is_string($key) ? $key : $key + 1,
                 $value,
-                is_int($value) || is_float($value) ? PDO::PARAM_INT : PDO::PARAM_STR
+                is_int($value) ? PDO::PARAM_INT
+                    : (is_bool($value) ? PDO::PARAM_BOOL
+                    : (is_null($value) ? PDO::PARAM_NULL
+                    : PDO::PARAM_STR))
             );
         }
     }
🧹 Nitpick comments (14)
tests/Scaffold/ScaffoldBaseTest.php (1)

23-29: Consider removing duplicate helper method.

The callProtectedMethod helper is already defined in the parent class \Winter\Storm\Tests\TestCase (see tests/TestCase.php lines 26-34) with an identical implementation. You can safely remove this duplicate and rely on the inherited method.

Apply this diff to remove the duplication:

-    protected static function callProtectedMethod($object, $name, $params = [])
-    {
-        $className = get_class($object);
-        $class = new ReflectionClass($className);
-        $method = $class->getMethod($name);
-        $method->setAccessible(true);
-        return $method->invokeArgs($object, $params);
-    }
-
src/Support/Facades/File.php (1)

47-47: Consider using unqualified class name for consistency.

Since the FilesystemAdapter import is now present (line 4), you could simplify the fully qualified reference here to match the style used in lines 57-58.

Apply this diff for consistency:

- * @method static bool isLocalDisk(\Illuminate\Filesystem\FilesystemAdapter $disk)
+ * @method static bool isLocalDisk(FilesystemAdapter $disk)
tests/Parse/YamlTest.php (1)

8-8: LGTM! Change correctly aligns with test-suite standardization.

The update to extend \Winter\Storm\Tests\TestCase is correct and consistent with the broader test-suite consolidation described in the PR objectives.

For improved readability and consistency with modern PHP conventions, consider adding a use statement at the top of the file instead of using the fully-qualified namespace in the class declaration:

 use Winter\Storm\Parse\Processor\YamlProcessor;
 use Symfony\Component\Yaml\Exception\ParseException;
 use Winter\Storm\Parse\Processor\Symfony3Processor;
 use Winter\Storm\Parse\Yaml as YamlParser;
+use Winter\Storm\Tests\TestCase;
 
-class YamlTest extends \Winter\Storm\Tests\TestCase
+class YamlTest extends TestCase
tests/Database/UpdaterTest.php (1)

7-7: Consider removing the nullable type since setUp() always initializes the property.

The nullable type and null default appear defensive but are functionally unnecessary since PHPUnit guarantees setUp() runs before each test method. The property is never actually null during test execution.

Consider simplifying to a non-nullable property without a default value:

-    protected ?Updater $updater = null;
+    protected Updater $updater;

This relies on setUp() to initialize the property (which it always does on line 13) and better expresses the invariant that the property is always initialized when tests run.

tests/Foundation/ProviderRepositoryTest.php (1)

49-49: Optional: Fix method name typos.

The method names testOriginalFunctionaliy and testWinterFunctionaliy contain typos (should be "Functionality"). While not new to this PR, consider correcting these for better code clarity.

Also applies to: 67-67

tests/Support/CountableTest.php (1)

2-2: LGTM! Explicit base class reference improves clarity.

The fully-qualified reference to \Winter\Storm\Tests\TestCase makes the inheritance chain explicit and aligns with the PR-wide standardization of test infrastructure.

Optional: Consider adding a namespace declaration.

The file lacks a namespace declaration. Adding one (e.g., namespace Winter\Storm\Tests\Support;) would follow modern PHP best practices and better organize the test suite, though this may be intentional for the current test structure.

src/Auth/Models/Preferences.php (2)

63-63: Modern PHP syntax: explicit constructor call.

Using new static() with explicit parentheses aligns with modern PHP style guidelines and improves consistency.


116-117: Modern PHP syntax: short array destructuring.

The migration from list(...) to [...] syntax adopts PHP 7.1+ short array destructuring, improving code readability and consistency with modern PHP practices.

Also applies to: 177-177

src/Database/Traits/Revisionable.php (1)

160-161: Consider removing the redundant $this->dates check.

The condition checks in_array($attribute, $this->getDates()) followed by a direct check of in_array($attribute, $this->dates). Since getDates() already merges $this->dates with timestamp columns (as seen in HasAttributes.php lines 20-32), the final check appears redundant.

If the redundant check is for backward compatibility, consider documenting why it's necessary. Otherwise, simplify to:

-        if (in_array($attribute, $this->getDates()) || $this->isDateCastable($attribute) || in_array($attribute, $this->dates)) {
+        if (in_array($attribute, $this->getDates()) || $this->isDateCastable($attribute)) {
             return 'date';
         }
tests/Database/Relations/DynamicRelationTest.php (1)

11-11: LGTM: Test inheritance standardized.

The fully qualified base class name standardizes test inheritance across the suite. The import on line 9 makes this redundant, but the explicit qualification doesn't hurt.

Optionally, you could remove the import on line 9 or use the imported alias:

-class DynamicRelationTest extends \Winter\Storm\Tests\DbTestCase
+class DynamicRelationTest extends DbTestCase
tests/Database/Relations/AttachManyTest.php (1)

10-10: LGTM: Test inheritance standardized.

The fully qualified base class name aligns with the standardization effort across test files. As with DynamicRelationTest, the import on line 8 makes this redundant but not incorrect.

Optionally simplify by using the imported alias:

-class AttachManyTest extends \Winter\Storm\Tests\DbTestCase
+class AttachManyTest extends DbTestCase
src/Database/Connections/Connection.php (1)

5-7: Add a target removal version to the deprecation notice.

The @deprecated tag should include a version number indicating when this class will be removed to help users plan migrations.

Apply this diff:

 /*
- * @deprecated
+ * @deprecated Will be removed in v2.0. Extend driver-specific connections (MySqlConnection, PostgresConnection, etc.) instead.
  */
src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php (1)

24-24: Unused parameter $command can be removed.

The static analysis tool correctly identifies that $command is not used in this method.

However, retaining it may be intentional for interface consistency with other grammar implementations. If not needed, consider removing it:

-    public function compileChange(Blueprint $blueprint, Fluent $command)
+    public function compileChange(Blueprint $blueprint)
src/Database/Connections/HasConnection.php (1)

16-16: Consider adding native type hints to properties.

Both $doctrineConnection and $doctrineTypeMappings rely on PHPDoc for type information. Adding native PHP type hints would improve static analysis and reduce the PHPStan issues mentioned in the PR context.

Apply this diff:

-    protected $doctrineConnection = null;
+    protected ?DoctrineConnection $doctrineConnection = null;
-    protected $doctrineTypeMappings = [];
+    protected array $doctrineTypeMappings = [];

Also applies to: 23-23

Comment thread src/Auth/Models/User.php Outdated
Comment on lines +96 to +109
public function isDoctrineAvailable()
{
return class_exists('Doctrine\DBAL\Connection');
}

/**
* Indicates whether native alter operations will be used when dropping or renaming columns, even if Doctrine DBAL is installed.
*
* @return bool
*/
public function usingNativeSchemaOperations()
{
return ! $this->isDoctrineAvailable();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Clarify whether Doctrine DBAL is optional or required.

The runtime checks in isDoctrineAvailable() and usingNativeSchemaOperations() suggest Doctrine DBAL is optional, but the PR context indicates a required upgrade from Doctrine DBAL 2 to 3. Additionally, registerDoctrineType() (line 180-184) throws a RuntimeException if Doctrine is unavailable, further suggesting it's expected to be present.

If Doctrine DBAL is now a required dependency, these runtime checks and the exception in registerDoctrineType() may be unnecessary.

Run the following script to check if Doctrine DBAL is declared as a required or optional dependency:


🏁 Script executed:

#!/bin/bash
# Description: Check composer.json for Doctrine DBAL dependency status

# Check if doctrine/dbal is in require or require-dev
if [ -f "composer.json" ]; then
  echo "=== Checking Doctrine DBAL dependency status ==="
  cat composer.json | jq -r '.require["doctrine/dbal"] // "NOT IN REQUIRE"'
  echo "---"
  cat composer.json | jq -r '."require-dev"["doctrine/dbal"] // "NOT IN REQUIRE-DEV"'
else
  echo "composer.json not found"
fi

Length of output: 76


Let me verify how these methods are used and check the registerDoctrineType() method context:


🏁 Script executed:

#!/bin/bash
# Find usages of isDoctrineAvailable and usingNativeSchemaOperations
echo "=== Usages of isDoctrineAvailable() ==="
rg -n "isDoctrineAvailable\(\)" --type=php

echo ""
echo "=== Usages of usingNativeSchemaOperations() ==="
rg -n "usingNativeSchemaOperations\(\)" --type=php

echo ""
echo "=== registerDoctrineType() method (lines 180-184) ==="
rg -n -A8 "registerDoctrineType" --type=php

Length of output: 1903


Let me check if usingNativeSchemaOperations() is actually being used anywhere:


🏁 Script executed:

#!/bin/bash
# Search more broadly for usage of usingNativeSchemaOperations
echo "=== All references to usingNativeSchemaOperations ==="
rg -n "usingNativeSchemaOperations" --type=php

echo ""
echo "=== Check in test files ==="
rg -n "usingNativeSchemaOperations" --type=php -g '**test*'

echo ""
echo "=== Check in all files (any extension) ==="
rg -n "usingNativeSchemaOperations"

Length of output: 320


Remove or update dead code related to optional Doctrine support.

Doctrine DBAL is a required dependency (^3.0 in composer.json), but the code maintains runtime checks suggesting it's optional:

  • usingNativeSchemaOperations() is never called anywhere in the codebase and always returns false (dead code).
  • registerDoctrineType() throws a RuntimeException if Doctrine is unavailable, but since Doctrine is required, this exception path is unreachable.
  • These checks contradict the actual dependency status and should be removed or the logic clarified.

Comment on lines +134 to +138
// Doctrine v2 expects one parameter while v3 expects two. 2nd will be ignored on v2...
return $this->getDoctrineDriver()->getSchemaManager(
$connection,
$connection->getDatabasePlatform()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify Doctrine DBAL v2/v3 compatibility approach.

The comment states "Doctrine v2 expects one parameter while v3 expects two. 2nd will be ignored on v2..." However, passing two arguments to a method expecting one in PHP will typically result in an ArgumentCountError rather than silently ignoring the extra parameter.

If this PR only targets Doctrine DBAL v3 (as suggested by the PR context of "Doctrine DBAL 2 → 3"), the comment may be outdated and the code is correct. If both v2 and v3 support is required, you'll need conditional logic based on the Doctrine version.

Run the following script to check the Doctrine DBAL version constraints and verify the compatibility approach:


🏁 Script executed:

#!/bin/bash
# Description: Check Doctrine DBAL version constraint and verify getSchemaManager signature

echo "=== Doctrine DBAL Version Constraint ==="
if [ -f "composer.json" ]; then
  cat composer.json | jq -r '.require["doctrine/dbal"] // .["require-dev"]["doctrine/dbal"] // "NOT FOUND"'
fi

echo ""
echo "=== Checking for Doctrine v2/v3 compatibility code ==="
# Search for version checks or conditional logic related to Doctrine
rg -n "doctrine.*version|DBAL.*version" --type php -i

echo ""
echo "=== Check if there are any other Doctrine compatibility comments ==="
rg -n "doctrine v[0-9]|dbal v[0-9]" --type php -i

Length of output: 314


Update or remove the misleading Doctrine v2 compatibility comment.

The codebase targets only Doctrine DBAL v3 (^3.0 in composer.json), with no conditional logic for v2 support. The comment at line 134 suggesting v2 compatibility is outdated and misleading.

Recommended fix: Replace the comment with clarification that the two-parameter call is required for Doctrine DBAL v3:

        // Doctrine DBAL v3 requires SchemaManager to accept Connection and AbstractPlatform
        return $this->getDoctrineDriver()->getSchemaManager(
            $connection,
            $connection->getDatabasePlatform()
        );
🤖 Prompt for AI Agents
In src/Database/Connections/HasConnection.php around lines 134 to 138, the
existing comment claiming Doctrine v2 compatibility is outdated and misleading;
update it to state that Doctrine DBAL v3 requires passing both the Connection
and its AbstractPlatform, then replace the old comment with a concise
clarification that the two-parameter call is required for Doctrine DBAL v3 while
leaving the return call unchanged.

Comment on lines +186 to +189
if (! Type::hasType($name)) {
Type::getTypeRegistry()
->register($name, is_string($class) ? new $class() : $class);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Doctrine DBAL v3-only API may break v2 compatibility.

The code uses Type::getTypeRegistry()->register() which is Doctrine DBAL v3 API. In Doctrine DBAL v2, the equivalent was Type::addType($name, $class).

Combined with the v2/v3 compatibility comment in getDoctrineSchemaManager() (line 134), there's inconsistency in the compatibility approach. If this PR drops Doctrine DBAL v2 support entirely (as suggested by "Doctrine DBAL 2 → 3" in the PR description), this is fine. However, if v2 support must be maintained, you'll need conditional logic here.


🏁 Script executed:

#!/bin/bash
# Description: Search for other uses of Doctrine v3-specific APIs that might break v2 compatibility

echo "=== Searching for Type::getTypeRegistry() usage (v3 only) ==="
rg -n "Type::getTypeRegistry|getTypeRegistry\(\)" --type php

echo ""
echo "=== Searching for Type::addType() usage (v2 API) ==="
rg -n "Type::addType|addType\(" --type php

echo ""
echo "=== Check composer.lock for installed Doctrine version ==="
if [ -f "composer.lock" ]; then
  cat composer.lock | jq -r '.packages[] | select(.name == "doctrine/dbal") | {name: .name, version: .version}'
fi

Length of output: 258


I need to gather more information about the Doctrine version requirements and the PR context.

Now I need to verify the composer.json to confirm the Doctrine version requirements:


---

🏁 Script executed:

```shell
#!/bin/bash
# Check composer.json for Doctrine DBAL version requirements
echo "=== composer.json (if exists) ==="
if [ -f "composer.json" ]; then
  cat composer.json | jq '.require, .["require-dev"]' 2>/dev/null || grep -i doctrine composer.json
else
  echo "composer.json not found"
fi

echo ""
echo "=== Check HasConnection.php for version-related comments ==="
if [ -f "src/Database/Connections/HasConnection.php" ]; then
  cat src/Database/Connections/HasConnection.php | grep -i -E "(doctrine|dbal|v2|v3|version|compatibility|support)" | head -20
fi

Length of output: 119


Let me check for documentation files that might clarify the Doctrine version strategy:


---

🏁 Script executed:

```shell
#!/bin/bash
# Check for documentation files mentioning Doctrine version
echo "=== Checking README.md and similar ==="
fd -e md -e txt | head -20 | xargs grep -l -i "doctrine\|dbal\|v2\|v3" 2>/dev/null || echo "No docs found or no matches"

echo ""
echo "=== Check if composer.json exists elsewhere ==="
fd "composer.json" -o "composer.lock" 2>/dev/null

echo ""
echo "=== Check file context around line 186-189 in HasConnection.php ==="
if [ -f "src/Database/Connections/HasConnection.php" ]; then
  sed -n '170,200p' src/Database/Connections/HasConnection.php
fi

Length of output: 893


Let me check the getDoctrineSchemaManager() method at line 134 to verify the claimed compatibility comment:


---

🏁 Script executed:

```shell
#!/bin/bash
# Check lines around 134 in HasConnection.php for the claimed compatibility comment
if [ -f "src/Database/Connections/HasConnection.php" ]; then
  echo "=== Lines 120-150 around line 134 ==="
  sed -n '120,150p' src/Database/Connections/HasConnection.php
fi

Length of output: 943


Now I have the critical context! Let me verify whether there's any intent stated about dropping v2 support:


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for any conditional logic or version checks in the codebase
echo "=== Search for version detection or compatibility checks ==="
rg -n "isDoctrineAvailable|getDoctrineDriver|version" --type php src/ | head -15

echo ""
echo "=== Search for TODO/FIXME about v2/v3 ==="
rg -n "TODO|FIXME" --type php src/Database/Connections/HasConnection.php

Length of output: 1549


Fix Doctrine DBAL v2 compatibility or explicitly drop v2 support.

The code uses Type::getTypeRegistry()->register() (Doctrine DBAL v3+ only), but the codebase already demonstrates v2/v3 compatibility efforts elsewhere—specifically in getDoctrineSchemaManager() at lines 136-137, which includes a comment acknowledging both versions.

This creates an inconsistency: the schema manager supports both versions, but custom type registration breaks on v2. Either:

  • Add conditional logic here to use Type::addType() for v2 and Type::getTypeRegistry()->register() for v3
  • Or confirm v2 support is being dropped project-wide and remove the compatibility comment from getDoctrineSchemaManager()
🤖 Prompt for AI Agents
In src/Database/Connections/HasConnection.php around lines 186-189, the custom
type registration uses Type::getTypeRegistry()->register() which is DBAL
v3-only; update to detect DBAL version and use Type::addType() for v2 and
Type::getTypeRegistry()->register() for v3. Specifically, if
Type::getTypeRegistry method exists use the current register($name, $instance)
flow (instantiating when $class is a string); otherwise call
Type::addType($name, $className) where $className is the class string (if $class
is an object use get_class($class)), and keep the existing Type::hasType check
to avoid double registration.

$sql = sprintf(
'alter table %s alter column %s %s',
$this->wrapTable($blueprint),
$this->wrap($column),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: Passing ColumnDefinition to wrap() will cause failure.

The wrap() method expects a string identifier (column name), not a ColumnDefinition object. This will produce invalid SQL.

Apply this fix:

             $sql = sprintf(
                 'alter table %s alter column %s %s',
                 $this->wrapTable($blueprint),
-                $this->wrap($column),
+                $this->wrap($column->name),
                 $this->getType($column)
             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->wrap($column),
$this->wrap($column->name),
🤖 Prompt for AI Agents
In src/Database/Schema/Grammars/SqlServerGrammar.php around line 37, the code
passes a ColumnDefinition object into $this->wrap(), which expects a string
identifier; replace that argument with the column's name string (e.g.
$column->name or $column->getName() depending on the ColumnDefinition API) so
wrap() receives the actual column name and produces valid SQL.

Comment on lines +60 to +67
public function getDefaultValue($value)
{
if (is_string($value)) {
$value = preg_replace('#\'#', '', $value);
}

return parent::getDefaultValue($value);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Regex strips all quotes, corrupting values like "O'Reilly".

The preg_replace('#\'#', '', $value) removes every single quote in the string, corrupting default values that contain apostrophes.

Apply this fix to only strip the wrapping quotes:

     public function getDefaultValue($value)
     {
-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
+        if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
+            $value = substr($value, 1, -1);
         }
 
         return parent::getDefaultValue($value);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function getDefaultValue($value)
{
if (is_string($value)) {
$value = preg_replace('#\'#', '', $value);
}
return parent::getDefaultValue($value);
}
public function getDefaultValue($value)
{
if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
$value = substr($value, 1, -1);
}
return parent::getDefaultValue($value);
}
🤖 Prompt for AI Agents
In src/Database/Schema/Grammars/SqlServerGrammar.php around lines 60 to 67, the
current preg_replace strips every apostrophe inside the string (corrupting
values like "O'Reilly"); change the logic to only remove wrapping single quotes:
detect if the first and last characters are single quotes (or use a regex that
matches only leading and trailing single quotes, e.g. ^'(.*)'$) and strip those
so inner apostrophes are preserved, handling empty quoted strings correctly.

Comment on lines +71 to 77
* @param string $path
* @return string
*/
public function publicPath()
public function publicPath($path = '')
{
return $this->basePath;
return $this->joinPaths($this->basePath, $path);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify publicPath implementation aligns with Winter CMS directory structure.

The method signature correctly matches Laravel 12's API. However, the implementation uses $this->basePath directly rather than a dedicated public path property. In standard Laravel applications, the public path typically points to a public subdirectory (e.g., basePath('public')), but Winter CMS may use the base path as its public directory.

Run the following script to verify the expected public directory structure:


🏁 Script executed:

#!/bin/bash
# Description: Check if Winter CMS uses a separate 'public' directory or if public path equals base path

# Check for documentation or configuration mentioning public directory structure
rg -n -C3 "public.*path|publicPath" --type=php -g '!vendor/**' -g '!tests/**'

# Look for index.php or public-facing entry points
fd -t f "index.php" -E vendor -E node_modules -x echo "Found index.php at: {}"

# Check if there's a 'public' directory in typical Winter CMS structure
fd -t d "^public$" -d 2 -E vendor -E node_modules

Length of output: 16957


Now I need to search the codebase to see if there's a $publicPath property or if Winter CMS actually uses basePath as the public path.

publicPath method implementation does not match Winter CMS directory structure.

The publicPath method should return the path to the public/web directory, but the current implementation returns $this->joinPaths($this->basePath, $path), which returns the base path, not the public directory. Winter CMS creates a separate public directory in the project root, so publicPath() should append 'public' to the base path.

Fix required: Change line 76 to: return $this->joinPaths($this->basePath, 'public', $path);

🤖 Prompt for AI Agents
In src/Foundation/Application.php around lines 71 to 77, the publicPath() method
currently returns the base path instead of the project's public/web directory;
update the return to join the base path with the public directory and the
optional $path (i.e., call joinPaths($this->basePath, 'public', $path)) so
publicPath() correctly points to the public folder and still handles an empty
$path gracefully.

Comment on lines +10 to +13
public function testDummy()
{
$this->assertTrue(true, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the assertion call signature.

The second parameter to assertTrue() should be a failure message string, not a boolean. Line 12 passes true as the message parameter.

Apply this diff:

-        $this->assertTrue(true, true);
+        $this->assertTrue(true);

Or if a message is desired:

-        $this->assertTrue(true, true);
+        $this->assertTrue(true, 'Dummy assertion should pass');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testDummy()
{
$this->assertTrue(true, true);
}
public function testDummy()
{
$this->assertTrue(true);
}
🤖 Prompt for AI Agents
In tests/fixtures/events/EventTest.php around lines 10 to 13 the call
$this->assertTrue(true, true) uses a boolean as the second argument but
PHPUnit::assertTrue expects the second parameter to be a string message; fix by
removing the second argument so it reads $this->assertTrue(true) or, if you need
a message, replace the second argument with a string like
$this->assertTrue(true, 'optional failure message').

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/Filesystem/FilesystemTest.php (1)

10-13: Call the parent setup so the Testbench app boots

Skipping parent::setUp(); means the Winter TestCase never initializes the Testbench application, so any code that expects the container/config to exist will break under Laravel 12’s harness. Please invoke the parent before creating the Filesystem instance.

     public function setUp(): void
     {
+        parent::setUp();
+
         $this->filesystem = new Filesystem();
     }
♻️ Duplicate comments (19)
src/Foundation/Http/Middleware/CheckForMaintenanceMode.php (1)

26-45: Re-throw non-503 HttpExceptions to avoid silent suppression.

This issue was already flagged in the previous review but remains unresolved. The catch block captures all HttpException instances but only handles status 503. Non-503 HTTP errors (400, 401, 404, 500, etc.) are silently suppressed, causing the middleware to return null and breaking the expected error-handling flow.

Apply the fix suggested in the previous review:

             return Response::make(View::make($view, [
                 'message'           => $ex->getMessage()
             ]), 503);
         }
+
+        throw $ex;
     }
 }
src/Halcyon/MemoryCacheManager.php (1)

10-12: Critical: Constructor signature mismatch with MemoryRepository.

This change passes a $config parameter to MemoryRepository, but the class does not define a constructor that accepts this parameter. This will cause a fatal error at runtime when the repository is instantiated.

As noted in the previous review, MemoryRepository needs a constructor defined to accept the $config parameter. The suggested fix from the prior review remains valid:

public function __construct(Store $store, array $config = [])
{
    parent::__construct($store);
    // Store or handle $config as needed
}

Verify whether MemoryRepository has been updated to accept this parameter:

#!/bin/bash
# Check if MemoryRepository constructor accepts $config parameter
file="src/Halcyon/MemoryRepository.php"
echo "=== Checking constructor in $file ==="
rg -n "function __construct" "$file" -A5
src/Support/helpers-array.php (1)

303-306: Implementation correct for Laravel 12; breaking change already flagged.

The implementation correctly calls Arr::shuffle($array) without a seed parameter, aligning with Laravel 12's API. The breaking change (removal of the $seed parameter) was comprehensively covered in the previous review, including verification scripts and migration guidance.

src/Network/Http.php (1)

276-277: Critical: SSL verification remains disabled.

Changing CURLOPT_SSL_VERIFYHOST from false to 0 doesn't improve security—both values disable host verification. For proper SSL verification, CURLOPT_SSL_VERIFYHOST should be set to 2 (verify that the certificate's Common Name or Subject Alternative Name matches the requested hostname).

As noted in the previous review, the current default makes all HTTPS requests vulnerable to man-in-the-middle attacks unless users explicitly call verifySSL().

Consider enabling SSL verification by default:

-        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
-        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);

Then introduce a noVerifySSL() method for explicit opt-out:

/**
 * Disable SSL verification (use only for development/testing)
 */
public function noVerifySSL()
{
    $this->setOption(CURLOPT_SSL_VERIFYPEER, false);
    $this->setOption(CURLOPT_SSL_VERIFYHOST, 0);
    return $this;
}
src/Database/PDO/Connection.php (4)

42-53: Don't rely on assert; handle PDO::exec() false explicitly

exec() can return false without throwing. Returning false violates the int return type and hides the error.


61-70: Guard against PDO::prepare() returning false

prepare() returns false on failure (no exception). The current code can trigger a TypeError in createStatement().


78-89: Guard against PDO::query() returning false

query() can return false. Avoid relying on assert().


158-161: quote() may return false; enforce string return

PDO::quote() returns string|false. Returning false violates the declared behavior.

composer.json (1)

60-60: Dev-branch dependency remains unpinned

The dependency on dev-add-phpunit-11-support is still a floating dev branch, which can break without notice.

src/Database/PDO/Concerns/ConnectsToDatabase.php (1)

24-26: Correct the exception branding.

The guard still throws “Laravel requires…”, which is inaccurate for Winter CMS. Please update the message so diagnostics reference Winter instead.

-        if (! isset($params['pdo']) || ! $params['pdo'] instanceof PDO) {
-            throw new InvalidArgumentException('Laravel requires the "pdo" property to be set and be a PDO instance.');
-        }
+        if (! isset($params['pdo']) || ! $params['pdo'] instanceof PDO) {
+            throw new InvalidArgumentException('Winter requires the "pdo" property to be set and be a PDO instance.');
+        }
tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php (1)

49-52: Fix the expected nullable clause.

Postgres uses DROP NOT NULL to make a column nullable; 'alter column "name" null' isn’t valid SQL and will never match the grammar output. Please assert against drop not null.

-        $this->assertSame('alter column "name"  null', $parts[1]);
+        $this->assertSame('alter column "name" drop not null', $parts[1]);
tests/GrammarTestCase.php (1)

37-40: Return the mock directly.

($this->connection)() invokes the mock as a callable and will crash at runtime. Just return the mock instance (as previously requested) so consumers get the configured Connection.

     public function getConnection()
     {
-        return ($this->connection)();
+        return $this->connection;
     }
src/Database/Schema/Grammars/PostgresGrammar.php (1)

63-69: Trim only wrapping quotes when normalizing defaults.

preg_replace('#\'#', '', $value) deletes every apostrophe, so a default like 'O'Connor' becomes OConnor, corrupting schema diffs. Only strip matching outer quotes instead of all occurrences.

-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
-        }
+        if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
+            $value = substr($value, 1, -1);
+        }
src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php (2)

33-38: Fix ColumnDefinition wrapping in ALTER statements.

wrap() expects an identifier string; passing the ColumnDefinition instance triggers a fatal conversion error when compileChange() runs. Use the column name (and only wrap renameTo when present) so the ALTER statement can be generated.

-            $sql = sprintf(
-                '%s %s%s %s',
-                is_null($column->renameTo) ? 'modify' : 'change',
-                $this->wrap($column),
-                is_null($column->renameTo) ? '' : ' '.$this->wrap($column->renameTo),
-                $this->getType($column)
-            );
+            $sql = sprintf(
+                '%s %s%s %s',
+                is_null($column->renameTo) ? 'modify' : 'change',
+                $this->wrap($column->name),
+                is_null($column->renameTo) ? '' : ' ' . $this->wrap($column->renameTo),
+                $this->getType($column)
+            );

59-63: Preserve inner quotes in default values.

preg_replace('#\'#', '', $value) removes every single quote, so defaults like O'Reilly become OReilly. Strip only a single pair of wrapping quotes before delegating to the parent.

-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
-        }
+        if (is_string($value) && strlen($value) >= 2 && $value[0] === "'" && substr($value, -1) === "'") {
+            $value = substr($value, 1, -1);
+        }
src/Database/Relations/Concerns/DeferOneOrMany.php (1)

127-133: Return the raw expression for deferred key lookups.

By coercing the raw CAST expression to string (and typing the method as string), the query builder now wraps it as an identifier in orWhereIn/whereNotIn, producing invalid SQL. Restore the previous behaviour by returning the Expression instance itself.

-    protected function getWithDeferredQualifiedKeyName(): string
-    {
-        return $this->parent->getConnection()->raw(DbDongle::cast(
-            DbDongle::getTablePrefix() . $this->related->getQualifiedKeyName(),
-            'TEXT'
-        ))->getValue($this->parent->getGrammar());
-    }
+    protected function getWithDeferredQualifiedKeyName()
+    {
+        return $this->parent->getConnection()->raw(DbDongle::cast(
+            DbDongle::getTablePrefix() . $this->related->getQualifiedKeyName(),
+            'TEXT'
+        ));
+    }
src/Auth/Models/User.php (1)

326-338: Keep the setter in sync with the configurable password column.

The new $authPasswordName hook is great, but setPasswordAttribute still hardcodes 'password', so changing the column name writes the wrong attribute and leaves the real password un-hashed. Use getAuthPasswordName() inside the mutator so reads/writes stay aligned.

-        if ($this->exists && empty($value)) {
-            unset($this->attributes['password']);
-        } else {
-            $this->attributes['password'] = $value;
+        $passwordField = $this->getAuthPasswordName();
+
+        if ($this->exists && empty($value)) {
+            unset($this->attributes[$passwordField]);
+        } else {
+            $this->attributes[$passwordField] = $value;
             // Password has changed, log out all users
             $this->attributes['persist_code'] = null;
         }
tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php (1)

3-3: Fix namespace to match test autoloading.

This test class sits under tests/Database/... just like the others, which all live in Winter\Storm\Tests\…. Leaving the namespace as Tests\… breaks PSR-4 resolution and PHPUnit discovery, so the suite will never execute these assertions. Please restore the Winter\Storm\Tests\Database\Schema\Grammars prefix so the test runner can load it.

-namespace Tests\Database\Schema\Grammars;
+namespace Winter\Storm\Tests\Database\Schema\Grammars;
src/Database/Connections/MariaDbConnection.php (1)

70-78: Bind values with the correct PDO types.

Binding floats as PDO::PARAM_INT truncates decimals, and null / boolean values fall through to PDO::PARAM_STR. That leads to incorrect data writes and unexpected SQL semantics. Mirror PDO expectations by branching on is_null, is_bool, is_int, and is_float separately.

-            $statement->bindValue(
-                is_string($key) ? $key : $key + 1,
-                $value,
-                is_int($value) || is_float($value) ? PDO::PARAM_INT : PDO::PARAM_STR
-            );
+            $statement->bindValue(
+                is_string($key) ? $key : $key + 1,
+                $value,
+                match (true) {
+                    is_null($value) => PDO::PARAM_NULL,
+                    is_bool($value) => PDO::PARAM_BOOL,
+                    is_int($value) => PDO::PARAM_INT,
+                    default => PDO::PARAM_STR,
+                }
+            );
🧹 Nitpick comments (16)
tests/Database/Traits/DeferredBindingTest.php (1)

9-11: Remove redundant import statement.

The use statement on line 9 imports DbTestCase, but line 11 uses the fully-qualified \Winter\Storm\Tests\DbTestCase. Either remove the import or use the unqualified name in the class declaration.

Apply this diff to remove the redundant import:

 namespace Winter\Storm\Tests\Database\Traits;
 
 use Winter\Storm\Database\Models\DeferredBinding;
 use Winter\Storm\Database\Model;
 use Winter\Storm\Tests\Database\Fixtures\Post;
 use Winter\Storm\Tests\Database\Fixtures\Author;
-use Winter\Storm\Tests\DbTestCase;
 
 class DeferredBindingTest extends \Winter\Storm\Tests\DbTestCase
 {
src/Support/Facades/File.php (1)

47-47: Consider using the short name for consistency.

Now that FilesystemAdapter is imported at line 4, this line can use the short name instead of the fully qualified namespace to match the style used in lines 57-58.

Apply this diff:

-* @method static bool isLocalDisk(\Illuminate\Filesystem\FilesystemAdapter $disk)
+* @method static bool isLocalDisk(FilesystemAdapter $disk)
tests/Database/Traits/SimpleTreeTest.php (1)

7-9: Consider removing the redundant import or using the imported alias.

Since line 9 now uses the fully qualified class name \Winter\Storm\Tests\DbTestCase, the import statement on line 7 is unused. For consistency, either remove the import or use the alias DbTestCase in the class declaration.

Option 1: Remove the unused import

-use Winter\Storm\Tests\DbTestCase;
 
 class SimpleTreeTest extends \Winter\Storm\Tests\DbTestCase

Option 2: Use the imported alias

 class SimpleTreeTest extends \Winter\Storm\Tests\DbTestCase
+class SimpleTreeTest extends DbTestCase

Note: If the fully qualified naming pattern is intentional across the PR for clarity or namespace consistency, then Option 1 would be preferred.

tests/Parse/SyntaxFieldParserTest.php (1)

379-404: Consider removing duplicate helper methods.

These helper methods (callProtectedMethod, getProtectedProperty, setProtectedProperty) duplicate functionality already available in the parent \Winter\Storm\Tests\TestCase class. The parent's callProtectedMethod is visible in the code snippets and serves the same purpose.

You can remove lines 379-404 and rely on the parent class implementation:

-    //
-    // Helpers
-    //
-
-    protected static function callProtectedMethod($object, $name, $params = [])
-    {
-        $className = get_class($object);
-        $class = new ReflectionClass($className);
-        $method = $class->getMethod($name);
-        $method->setAccessible(true);
-        return $method->invokeArgs($object, $params);
-    }
-
-    public static function getProtectedProperty($object, $name)
-    {
-        $className = get_class($object);
-        $class = new ReflectionClass($className);
-        $property = $class->getProperty($name);
-        $property->setAccessible(true);
-        return $property->getValue($object);
-    }
-
-    public static function setProtectedProperty($object, $name, $value)
-    {
-        $className = get_class($object);
-        $class = new ReflectionClass($className);
-        $property = $class->getProperty($name);
-        $property->setAccessible(true);
-        return $property->setValue($object, $value);
-    }
tests/Validation/RuleObjectTest.php (1)

8-8: LGTM! Consider adding a use statement for consistency.

The fully-qualified base class reference is correct and aligns with the PR-wide namespace refactoring for Laravel 12 compatibility.

For style consistency with the other imports (lines 3-6), you could optionally add a use statement at the top:

 use Illuminate\Filesystem\Filesystem;
 use Winter\Storm\Translation\FileLoader;
 use Winter\Storm\Translation\Translator;
 use Winter\Storm\Validation\Factory;
+use Winter\Storm\Tests\TestCase;

-class RuleObjectTest extends \Winter\Storm\Tests\TestCase
+class RuleObjectTest extends TestCase
tests/Foundation/ApplicationTest.php (1)

6-6: Consider using a use statement for cleaner code.

The fully-qualified namespace \Winter\Storm\Tests\TestCase is explicit and correct, but PHP convention typically favors importing the class with a use statement at the top of the file for improved readability.

Apply this diff if you prefer the conventional approach:

+use Winter\Storm\Tests\TestCase;
+
-class ApplicationTest extends \Winter\Storm\Tests\TestCase
+class ApplicationTest extends TestCase

Note: If the FQN usage is part of an intentional pattern across the test suite refactor, feel free to keep it as-is.

tests/Halcyon/ValidationTraitTest.php (1)

3-6: Consider applying the named class pattern to tests/Database/Traits/ValidationTest.php for consistency.

The script confirmed that only one other test file uses the anonymous class pattern: tests/Database/Traits/ValidationTest.php (line 125). Since the named class approach is better suited for static analysis tools (relevant to the PR's PHPStan concerns), applying the same pattern to that file would improve consistency across trait tests.

src/Foundation/Bootstrap/LoadConfiguration.php (1)

45-46: Consider investigating PHPStan concerns with env() calls.

Both suppressions relate to env() usage. The PHPStan baseline has grown significantly (136→513 issues); investigating and fixing the root type issues would be preferable to suppressing them.

Also applies to: 56-57

src/Database/Traits/Revisionable.php (1)

160-161: Remove the redundant $this->dates check from line 161.

The condition in_array($attribute, $this->getDates()) already covers this case. getDates() returns either $this->dates directly (when timestamps are disabled) or array_merge($this->dates, $defaults) (when enabled), so checking in_array($attribute, $this->dates) separately is redundant.

Simplify to:

if (in_array($attribute, $this->getDates()) || $this->isDateCastable($attribute)) {
tests/Database/Traits/SortableTest.php (1)

5-7: Remove redundant import or use the imported alias consistently.

The file imports Winter\Storm\Tests\DbTestCase on line 5 but then uses the fully-qualified name \Winter\Storm\Tests\DbTestCase on line 7. Either remove the import and keep the fully-qualified reference, or use the imported alias DbTestCase.

Apply this diff to use the import alias:

-class SortableTest extends \Winter\Storm\Tests\DbTestCase
+class SortableTest extends DbTestCase

Or remove the unused import:

-use Winter\Storm\Tests\DbTestCase;
-
-class SortableTest extends \Winter\Storm\Tests\DbTestCase
+class SortableTest extends \Winter\Storm\Tests\DbTestCase
tests/Parse/ArrayFileTest.php (1)

5-5: Remove redundant import or use the imported alias consistently.

Similar to other test files in this PR, this file would benefit from consistency. The test class extends the fully-qualified \Winter\Storm\Tests\TestCase but there's no explicit import shown. Ensure consistency across the codebase.

src/Database/Builder.php (1)

85-95: Consider adding Expression handling in exact mode.

The searchWhereInternal method now handles Expression instances in lines 107-109 for 'any'/'all' modes, but the 'exact' mode (lines 85-95) doesn't include similar handling. If a column is passed as an Expression in exact mode, it won't be properly resolved.

Consider adding Expression handling in the exact mode block:

         if ($mode === 'exact') {
             $this->where(function (Builder $query) use ($columns, $term) {
                 foreach ($columns as $field) {
                     if (!strlen($term)) {
                         continue;
                     }
+                    if ($field instanceof Expression) {
+                        $field = $field->getValue($query->getQuery()->getGrammar());
+                    }
                     $fieldSql = $this->query->raw(sprintf("lower(%s)", DbDongle::cast($field, 'text')));
                     $termSql = '%' . trim(mb_strtolower($term)) . '%';
                     $query->orWhere($fieldSql, 'LIKE', $termSql);
                 }
             }, null, null, $boolean);
tests/Database/Relations/DuplicateRelationTest.php (1)

9-11: Consider removing the redundant import.

Since the class extends the fully-qualified \Winter\Storm\Tests\DbTestCase (line 11), the import statement on line 9 is no longer necessary and could be removed for cleaner code.

Apply this diff to remove the redundant import:

-use Winter\Storm\Tests\DbTestCase;
-
-class DuplicateRelationTest extends \Winter\Storm\Tests\DbTestCase
+class DuplicateRelationTest extends \Winter\Storm\Tests\DbTestCase
tests/Database/Relations/DynamicRelationTest.php (1)

9-11: Consider removing the redundant import.

The import statement on line 9 is redundant since the class extends clause uses the fully-qualified class name. This pattern appears across multiple test files in this PR.

Apply this diff:

-use Winter\Storm\Tests\DbTestCase;
-
-class DynamicRelationTest extends \Winter\Storm\Tests\DbTestCase
+class DynamicRelationTest extends \Winter\Storm\Tests\DbTestCase
tests/Database/Relations/BelongsToTest.php (1)

8-10: Consider removing the redundant import.

The import statement on line 8 is unnecessary since the extends clause on line 10 uses the fully-qualified class name.

Apply this diff:

-use Winter\Storm\Tests\DbTestCase;
-
-class BelongsToTest extends \Winter\Storm\Tests\DbTestCase
+class BelongsToTest extends \Winter\Storm\Tests\DbTestCase
tests/Database/Traits/RevisionableTest.php (1)

7-9: Consider removing the redundant import.

The import on line 7 is unnecessary since the extends clause uses the fully-qualified class name. This redundancy appears across multiple test files in this PR.

Apply this diff:

-use Winter\Storm\Tests\DbTestCase;
-
-class RevisionableTest extends \Winter\Storm\Tests\DbTestCase
+class RevisionableTest extends \Winter\Storm\Tests\DbTestCase

Comment thread src/Config/ConfigWriter.php Outdated
$ast = $parser->parse($contents);
} catch (Error $e) {
throw new SystemException($e);
throw new SystemException($e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix type mismatch in SystemException constructor.

The Error object is passed directly to SystemException, but the constructor expects a string message as the first parameter and calls HtmlBuilder::clean($message) on it. This will cause a type error at runtime.

Apply this diff to fix the type mismatch:

-            throw new SystemException($e);
+            throw new SystemException($e->getMessage(), $e->getCode(), $e);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw new SystemException($e);
throw new SystemException($e->getMessage(), $e->getCode(), $e);
🤖 Prompt for AI Agents
In src/Config/ConfigWriter.php around line 37, the code throws the Error object
directly into SystemException which expects a string message; change the throw
to pass the error message string (e.g. $e->getMessage()) as the first parameter
and, if SystemException supports a previous exception parameter, pass the
original $e as the second parameter so HtmlBuilder::clean receives a string and
the original error is preserved.

* @return int|false The exit code to return or false to continue the normal execution
*/
public function handleSignal(int $signal, /* int|false $previousExitCode = 0 */)
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify unused parameter is required for interface compliance.

The $previousExitCode parameter is never used in the method body. If this signature is required by the SignalableCommandInterface or parent class contract, the parameter should remain. Otherwise, consider removing it.

Run the following script to check if this method signature matches a parent or interface requirement:


🏁 Script executed:

#!/bin/bash
# Check if handleSignal is defined in any parent class or interface

# Search for handleSignal method definitions in interfaces and parent classes
rg -n --type=php -A3 'function handleSignal' -g '!HandlesCleanup.php'

# Search for SignalableCommandInterface
rg -n --type=php -A10 'interface SignalableCommandInterface'

Length of output: 0


I need to search more broadly to find the interface definition and understand the class context. Let me try a different approach:


---

🏁 Script executed:

```shell
#!/bin/bash
# Find HandlesCleanup.php and examine its structure
find . -name "HandlesCleanup.php" -type f

# Search for SignalableCommandInterface without exclusions
rg -n --type=php 'SignalableCommandInterface' 

# Search for handleSignal more broadly
rg -n --type=php 'handleSignal'

# Search for interface definitions in console directory
fd -e php . src/Console | head -20

Length of output: 695


Now let me examine the actual method implementation:


---

🏁 Script executed:

```shell
#!/bin/bash
# View the handleSignal method implementation in HandlesCleanup.php
cat src/Console/Traits/HandlesCleanup.php | sed -n '40,70p'

# Also check if $previousExitCode is used anywhere in the file
rg -n 'previousExitCode' src/Console/Traits/HandlesCleanup.php

Length of output: 1031


Let me verify the Symfony interface requirement:


---

🌐 Web query:

```web
Symfony SignalableCommandInterface handleSignal method signature

💡 Result:

Signature (Symfony 6.3+):
public function handleSignal(int $signal): int|false

(Older Symfony versions used : void; returning no value is deprecated since 6.3.) [1][2]


Remove the unused $previousExitCode parameter to comply with SignalableCommandInterface.

The Symfony interface signature is public function handleSignal(int $signal): int|false, but the implementation includes an extra parameter that is not used and violates the interface contract. Remove int|false $previousExitCode = 0 from line 46.

🧰 Tools
🪛 PHPMD (2.15.0)

46-46: Avoid unused parameters such as '$previousExitCode'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
In src/Console/Traits/HandlesCleanup.php around line 46, the method signature
for handleSignal includes an unused extra parameter `int|false $previousExitCode
= 0` which violates Symfony's SignalableCommandInterface; remove the
`$previousExitCode` parameter so the signature becomes `public function
handleSignal(int $signal): int|false`, and update any internal references (none
expected) and method calls to match the interface.

Comment on lines +76 to +78
$this->app->singleton('db.transactions', function ($app) {
return new DatabaseTransactionsManager;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Inject the database manager into DatabaseTransactionsManager.

DatabaseTransactionsManager expects the DatabaseManager in its constructor. Instantiating it with no arguments will immediately throw a TypeError when the binding is resolved.

-        $this->app->singleton('db.transactions', function ($app) {
-            return new DatabaseTransactionsManager;
-        });
+        $this->app->singleton('db.transactions', function ($app) {
+            return new DatabaseTransactionsManager($app['db']);
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->app->singleton('db.transactions', function ($app) {
return new DatabaseTransactionsManager;
});
$this->app->singleton('db.transactions', function ($app) {
return new DatabaseTransactionsManager($app['db']);
});
🤖 Prompt for AI Agents
In src/Database/DatabaseServiceProvider.php around lines 76 to 78, the singleton
binding returns new DatabaseTransactionsManager with no arguments but the class
requires a DatabaseManager in its constructor; change the factory to resolve and
pass the database manager from the container (e.g.
$app->make(\Illuminate\Database\DatabaseManager::class) or $app['db']) into the
DatabaseTransactionsManager constructor so the dependency is injected when the
singleton is created.

Comment on lines +27 to +55
$changes = [$this->compileDropDefaultConstraint($blueprint, $command)];
$schema = $this->connection->getSchemaBuilder();
$table = $blueprint->getTable();

$oldColumns = collect($schema->getColumns($table));

foreach ($blueprint->getChangedColumns() as $column) {
$sql = sprintf(
'alter table %s alter column %s %s',
$this->wrapTable($blueprint),
$this->wrap($column),
$this->getType($column)
);

$oldColumn = $oldColumns->where('name', $column->name)->first();
if (!$oldColumn instanceof ColumnDefinition) {
$oldColumn = new ColumnDefinition($oldColumn);
}

foreach ($this->modifiers as $modifier) {
if (method_exists($this, $method = "modify{$modifier}")) {
$mod = strtolower($modifier);
$col = isset($oldColumn->{$mod}) && !isset($column->{$mod}) ? $oldColumn : $column;
$sql .= $this->{$method}($blueprint, $col);
}
}

$changes[] = $sql;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Initialize $changes without creating nested arrays.

compileDropDefaultConstraint may return an array of drop statements (Laravel 12 does this). Wrapping that array in another array leaves $changes shaped like [ [ ... ], 'alter ...' ], so the schema builder later tries to execute an array as SQL. Cast to array instead of wrapping it to keep the list flat.

-        $changes = [$this->compileDropDefaultConstraint($blueprint, $command)];
+        $changes = (array) $this->compileDropDefaultConstraint($blueprint, $command);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$changes = [$this->compileDropDefaultConstraint($blueprint, $command)];
$schema = $this->connection->getSchemaBuilder();
$table = $blueprint->getTable();
$oldColumns = collect($schema->getColumns($table));
foreach ($blueprint->getChangedColumns() as $column) {
$sql = sprintf(
'alter table %s alter column %s %s',
$this->wrapTable($blueprint),
$this->wrap($column),
$this->getType($column)
);
$oldColumn = $oldColumns->where('name', $column->name)->first();
if (!$oldColumn instanceof ColumnDefinition) {
$oldColumn = new ColumnDefinition($oldColumn);
}
foreach ($this->modifiers as $modifier) {
if (method_exists($this, $method = "modify{$modifier}")) {
$mod = strtolower($modifier);
$col = isset($oldColumn->{$mod}) && !isset($column->{$mod}) ? $oldColumn : $column;
$sql .= $this->{$method}($blueprint, $col);
}
}
$changes[] = $sql;
}
$changes = (array) $this->compileDropDefaultConstraint($blueprint, $command);
$schema = $this->connection->getSchemaBuilder();
$table = $blueprint->getTable();
$oldColumns = collect($schema->getColumns($table));
foreach ($blueprint->getChangedColumns() as $column) {
$sql = sprintf(
'alter table %s alter column %s %s',
$this->wrapTable($blueprint),
$this->wrap($column),
$this->getType($column)
);
$oldColumn = $oldColumns->where('name', $column->name)->first();
if (!$oldColumn instanceof ColumnDefinition) {
$oldColumn = new ColumnDefinition($oldColumn);
}
foreach ($this->modifiers as $modifier) {
if (method_exists($this, $method = "modify{$modifier}")) {
$mod = strtolower($modifier);
$col = isset($oldColumn->{$mod}) && !isset($column->{$mod}) ? $oldColumn : $column;
$sql .= $this->{$method}($blueprint, $col);
}
}
$changes[] = $sql;
}
🤖 Prompt for AI Agents
In src/Database/Schema/Grammars/SqlServerGrammar.php around lines 27 to 55,
$changes is being initialized as an array that contains the result of
compileDropDefaultConstraint, but that method can already return an array, which
produces a nested array and later causes the schema builder to attempt executing
an array as SQL; change the initialization to flatten the result by casting to
array or merging (e.g. $changes = (array)
$this->compileDropDefaultConstraint($blueprint, $command);) so $changes is a
flat list of SQL strings and subsequent code can append alter statements
normally.

Comment on lines +561 to 562
// @phpstan-ignore-next-line
$reflector = new ReflectionClass($this);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Consider investigating the root cause rather than suppressing.

The PHPStan suppression is part of a broader baseline expansion (136→513 issues). The PR discussion suggests triaging and fixing these issues rather than blanket-baselining them.

Run the following script to check what PHPStan error is being suppressed here:


🏁 Script executed:

#!/bin/bash
# Remove the phpstan-ignore and run analysis to see the actual error
vendor/bin/phpstan analyze src/Extension/ExtendableTrait.php --level=5 --no-progress 2>&1 | grep -A3 "line 562"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if phpstan exists and what version
if command -v vendor/bin/phpstan &> /dev/null; then
  echo "=== PHPStan Version ==="
  vendor/bin/phpstan --version
else
  echo "PHPStan not found in vendor/bin"
fi

# Try running phpstan on the file without grep filter
echo ""
echo "=== PHPStan analysis output ==="
vendor/bin/phpstan analyze src/Extension/ExtendableTrait.php --level=5 --no-progress 2>&1 | head -50

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Check the actual file content around the line in question
echo "=== File content around lines 560-565 ==="
sed -n '560,565p' src/Extension/ExtendableTrait.php | cat -n

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Look for any phpstan configuration files
echo "=== PHPStan config files ==="
find . -maxdepth 2 -name "phpstan.neon*" -o -name ".phpstan.*" | head -20

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Get broader context around the suppression - full method
echo "=== Context: Full method containing line 562 ==="
sed -n '550,575p' src/Extension/ExtendableTrait.php | cat -n

Length of output: 1110


🏁 Script executed:

#!/bin/bash
# Check class/trait definition at top of file
echo "=== File header and class/trait definition ==="
head -50 src/Extension/ExtendableTrait.php | cat -n

Length of output: 1768


🏁 Script executed:

#!/bin/bash
# Check phpstan.neon configuration
echo "=== PHPStan configuration ==="
cat ./phpstan.neon

Length of output: 567


🏁 Script executed:

#!/bin/bash
# Search for other phpstan-ignore suppressions in the same file
echo "=== Count of phpstan suppressions in this file ==="
grep -n "phpstan-ignore" src/Extension/ExtendableTrait.php | wc -l
echo ""
echo "=== All phpstan-ignore suppressions in this file ==="
grep -n "phpstan-ignore" src/Extension/ExtendableTrait.php

Length of output: 151


Fix the parameter type annotation instead of suppressing the error.

The suppression masks a legitimate type safety issue. The parameter $instance is typed as ?object (generic object), but line 15 calls $reflector->getParentClass(), which only exists on ReflectionClass. The fix is to change the parameter type from ?object $instance to ?ReflectionClass $instance so PHPStan can verify type safety, then remove the suppression at line 561.

🤖 Prompt for AI Agents
In src/Extension/ExtendableTrait.php around lines 561-562, remove the "//
@phpstan-ignore-next-line" and change the parameter type for $instance from
?object to ?ReflectionClass in the method signature that uses
$reflector->getParentClass(); update imports to include ReflectionClass (or use
\ReflectionClass) and adjust any callers if they currently pass a generic object
so they provide a ReflectionClass or null; this fixes the type-safety issue and
lets PHPStan validate the code.

Comment on lines +71 to 77
* @param string $path
* @return string
*/
public function publicPath()
public function publicPath($path = '')
{
return $this->basePath;
return $this->joinPaths($this->basePath, $path);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify that publicPath should return basePath instead of a public directory path.

The method name and docblock indicate this should return "the path to the public / web directory," but the implementation returns $this->basePath (when $path is empty). In standard Laravel, publicPath() returns the path to the public directory (e.g., /var/www/html/public), not the application base path (e.g., /var/www/html).

If Winter CMS intentionally places public files at the base path (no separate public directory), this is correct but should be documented. Otherwise, this appears to be a bug that could break file path resolution.

Additionally, this method uses joinPaths while other path methods in this class use PathResolver::join (see line 86 in langPath). Consider using PathResolver::join for consistency.

Run the following script to check how other Winter CMS/Storm path methods are implemented and whether a public directory exists:


🏁 Script executed:

#!/bin/bash
# Check if there's a publicPath property or constant that should be used
rg -n "publicPath\s*=" --type=php

# Check how publicPath was used in the old Laravel/Illuminate base class
rg -n "function publicPath" --type=php -A 5

# Look for references to 'public' directory in the codebase
rg -n -C3 "['\"]/public['\"]" --type=php | head -50

# Check other path resolution patterns in Application.php
rg -n "PathResolver::join.*basePath" --type=php

Length of output: 1894



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for tests or usages of publicPath() method
rg -n "publicPath\(\)" --type=php -B2 -A2

# Check if there are any tests for the publicPath method
rg -n "publicPath" tests/ --type=php

# Look for any constants or properties defining public directory structure
rg -n "'public'|\"public\"" src/Foundation/Application.php -C2

# Check the joinPaths vs PathResolver::join usage in the file
rg -n "joinPaths|PathResolver::join" src/Foundation/Application.php

Length of output: 188


🏁 Script executed:

#!/bin/bash
# Search for usages and tests of publicPath() method
echo "=== publicPath() usages in codebase ==="
rg -n "publicPath\(\)" --type=php -B2 -A2

echo "=== publicPath tests ==="
rg -n "publicPath" tests/ --type=php -C2

echo "=== joinPaths method definition ==="
rg -n "function joinPaths|def joinPaths" --type=php -A5

echo "=== PathResolver::join vs joinPaths usage in Application.php ==="
rg -n "joinPaths|PathResolver::join" src/Foundation/Application.php -C1

Length of output: 1608


🏁 Script executed:

#!/bin/bash
# Find joinPaths method definition - check if it's in Application.php or parent class
echo "=== joinPaths definition in Application.php ==="
rg -n "function joinPaths|public function joinPaths" src/Foundation/Application.php -A3

# Check parent class or trait for joinPaths
echo "=== joinPaths in parent Application class or traits ==="
rg -n "joinPaths" src/Foundation/Application.php -B5 | head -20

# Look at class definition and extends
echo "=== Application class definition and parent ==="
head -30 src/Foundation/Application.php | grep -A5 "class Application"

# Look for any public directory constant or property
echo "=== public directory references ==="
rg -n "public.*=.*['\"]public" src/Foundation/Application.php

Length of output: 480


🏁 Script executed:

#!/bin/bash
# Find ApplicationBase import
echo "=== ApplicationBase import ==="
head -50 src/Foundation/Application.php | grep -E "use.*ApplicationBase|use.*Illuminate"

# Check if there's any documentation or comments about publicPath design
echo "=== Comments/docs around publicPath ==="
sed -n '65,80p' src/Foundation/Application.php

# Check git history for publicPath changes
echo "=== Recent publicPath changes ==="
git log -p --all -S "publicPath" -- src/Foundation/Application.php | head -100

# Look for any README or documentation about directory structure
echo "=== Directory structure docs ==="
find . -maxdepth 2 -name "*.md" -type f | xargs grep -l "public\|directory\|structure" 2>/dev/null | head -5

Length of output: 671



🏁 Script executed:

#!/bin/bash
# Check if there's a public directory in the repo
echo "=== Looking for public directories ==="
find . -maxdepth 3 -type d -name "public" 2>/dev/null

# Check if there's a storage/public or any public-like structure
echo "=== Directory structure at root ==="
ls -la | head -20

# Check what Laravel's publicPath typically expects
echo "=== Testing if joinPaths works like PathResolver::join ==="
rg -n "class.*joinPaths|function joinPaths" --type=php -A5 vendor/ 2>/dev/null | head -30

# Look for configuration files that might define paths
echo "=== Config files mentioning paths ==="
find . -maxdepth 3 -name "config.php" -o -name "*config*.php" 2>/dev/null | head -5

Length of output: 1290


🏁 Script executed:

#!/bin/bash
# Check if there's a public directory in the repo
echo "=== Looking for public directories ==="
find . -maxdepth 3 -type d -name "public" 2>/dev/null

# Check directory structure at root
echo "=== Root directory structure ==="
ls -la . | grep -E "^d" | head -20

# Check if joinPaths is standard Laravel utility
echo "=== Checking if joinPaths is from Laravel base class ==="
rg -n "joinPaths" vendor/laravel/framework/src/Illuminate/Foundation/Application.php -A2 2>/dev/null | head -20

# Look for how publicPath is typically used in Laravel/Illuminate
echo "=== Searching for public_path usage in codebase ==="
rg -n "public_path\(\)" --type=php -C2 | head -30

Length of output: 865


🌐 Web query:

What does Laravel's publicPath method return and how is it typically used?

💡 Result:

Laravel's publicPath (and the public_path() helper) returns the fully-qualified filesystem path to your application's public directory (e.g. /var/www/html/project/public). You can also pass a filename to get a path inside that directory: public_path('css/app.css') → /…/project/public/css/app.css. (laravel.com)

Typical uses

  • Build filesystem paths to public assets or files you need to read/write (images, generated files). (laravel.com)
  • Create responses that serve files from the public directory (or pass paths to file helpers).
  • Combine with URLs or asset helpers when you need the server path (not the web URL). (laravel.com)

Example

  • public_path() // returns public dir
  • public_path('uploads/image.jpg') // returns full path to that file. (laravel.com)

Citations:


🏁 Script executed:

#!/bin/bash
# Final check: Does Application.php define a $publicPath property?
echo "=== Properties in Application.php ==="
rg -n "protected.*publicPath|private.*publicPath|public.*publicPath" src/Foundation/Application.php

# Check pattern of other path properties
echo "=== Path properties like pluginsPath ==="
rg -n "protected.*Path|private.*Path" src/Foundation/Application.php | head -20

Length of output: 385


Fix publicPath() implementation to append the '/public' subdirectory and use PathResolver::join for consistency.

The current implementation returns $this->basePath instead of the public directory path, which breaks the Laravel publicPath() contract. Laravel's publicPath method should return the fully-qualified filesystem path to the public directory (e.g., /var/www/html/project/public), not the base path.

Additionally, this method is inconsistent with all other path methods in this class:

  • All other path methods (pluginsPath, themesPath, tempPath, uploadsPath, mediaPath) use PathResolver::join($this->basePath, '/subdirectory')
  • All other path methods have corresponding protected properties ($pluginsPath, $themesPath, etc.)
  • publicPath() has neither a property nor uses PathResolver::join

Suggested fix: Either add a $publicPath property and follow the pattern of other methods, or at minimum change line 76 to:

return PathResolver::join($this->basePath, '/public', $path);
🤖 Prompt for AI Agents
In src/Foundation/Application.php around lines 71 to 77, publicPath()
incorrectly returns the base path and doesn't follow the class pattern; add a
protected $publicPath property (like other path properties) and implement
publicPath($path = '') to return PathResolver::join($this->basePath, '/public',
$path) so it yields the fully-qualified public directory path and matches the
other path methods' use of PathResolver::join.

Comment on lines +35 to 42
* @param \Winter\Storm\Mail\MailManager $manager
* @return \Winter\Storm\Support\Testing\Fakes\MailFake
*/
public static function fake()
public static function fake(MailManager $manager)
{
static::swap($fake = new MailFake);
static::swap($fake = new MailFake($manager));

return $fake;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep Mail::fake() backward compatible.

Requiring callers to pass a MailManager breaks the longstanding Mail::fake() API used across Winter and downstream apps. Have fake() resolve the manager from the container when none is supplied so existing tests keep working.

-    public static function fake(MailManager $manager)
+    public static function fake(?MailManager $manager = null)
     {
-        static::swap($fake = new MailFake($manager));
+        $manager ??= static::getFacadeApplication()->make(MailManager::class);
+
+        static::swap($fake = new MailFake($manager));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* @param \Winter\Storm\Mail\MailManager $manager
* @return \Winter\Storm\Support\Testing\Fakes\MailFake
*/
public static function fake()
public static function fake(MailManager $manager)
{
static::swap($fake = new MailFake);
static::swap($fake = new MailFake($manager));
return $fake;
* @param \Winter\Storm\Mail\MailManager $manager
* @return \Winter\Storm\Support\Testing\Fakes\MailFake
*/
public static function fake(?MailManager $manager = null)
{
$manager ??= static::getFacadeApplication()->make(MailManager::class);
static::swap($fake = new MailFake($manager));
return $fake;
🤖 Prompt for AI Agents
In src/Support/Facades/Mail.php around lines 35 to 42, make fake() backward
compatible by allowing the MailManager parameter to be optional: change the
signature to accept MailManager|null (or no type hint) with a default null, and
when null resolve the manager from the container (e.g.,
Container::getInstance()->make(MailManager::class) or app(MailManager::class)).
Keep the existing static::swap($fake = new MailFake($manager)) and return $fake,
and update the docblock to indicate the manager is optional/nullable so existing
callers can call Mail::fake() without passing a manager.

Comment on lines +3 to +13
class EventTest extends \Winter\Storm\Tests\TestCase
{
public function __construct()
{
parent::__construct("testDummy");
}

public function testDummy()
{
$this->assertTrue(true, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Event fixture incorrectly extends TestCase.

This file is an event fixture used in tests/Events/DispatcherTest.php (line 46: $dispatcher->dispatch(new EventTest)), but it has been changed to extend \Winter\Storm\Tests\TestCase. Event fixtures should be simple POPOs (Plain Old PHP Objects), not test cases.

Additionally, the code has multiple issues:

  • Line 7: Hardcoding "testDummy" in the parent constructor is non-standard and serves no purpose for an event fixture
  • Line 12: $this->assertTrue(true, true) has incorrect signature - the second parameter should be a message string, not a boolean

Revert this file to a simple event class:

 <?php
 
-class EventTest extends \Winter\Storm\Tests\TestCase
+class EventTest
 {
-    public function __construct()
-    {
-        parent::__construct("testDummy");
-    }
-
-    public function testDummy()
-    {
-        $this->assertTrue(true, true);
-    }
 }
🤖 Prompt for AI Agents
In tests/fixtures/events/EventTest.php around lines 3 to 13, the file was
incorrectly changed to extend \Winter\Storm\Tests\TestCase and include
test-specific code; revert it to a plain event POPO: remove the extends clause,
remove the constructor and any test methods/assertions, and restore it as a
simple class (optionally with any public properties or methods the
DispatcherTest expects) so it can be instantiated with new EventTest() during
dispatching.

Comment on lines 23 to 28
public function setUp(): void
{
$traitName = 'Winter\Storm\Support\Traits\Emitter';
$this->traitObject = $this->getObjectForTrait($traitName);
$this->traitObject = new class {
use \Winter\Storm\Support\Traits\Emitter;
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add parent::setUp() call to ensure proper test initialization.

The anonymous class approach correctly replaces the deprecated PHPUnit getObjectForTrait() method. However, the parent::setUp() call is missing, which could prevent proper test environment initialization when extending TestbenchTestCase.

Apply this diff to add the parent setUp call:

 public function setUp(): void
 {
+    parent::setUp();
+
     $this->traitObject = new class {
         use \Winter\Storm\Support\Traits\Emitter;
     };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function setUp(): void
{
$traitName = 'Winter\Storm\Support\Traits\Emitter';
$this->traitObject = $this->getObjectForTrait($traitName);
$this->traitObject = new class {
use \Winter\Storm\Support\Traits\Emitter;
};
}
public function setUp(): void
{
parent::setUp();
$this->traitObject = new class {
use \Winter\Storm\Support\Traits\Emitter;
};
}
🤖 Prompt for AI Agents
In tests/Support/EmitterTest.php around lines 23 to 28, the setUp() override is
missing a call to parent::setUp(), which can prevent proper testbench
initialization; modify the setUp() method to call parent::setUp() at the start
of the method, then proceed to instantiate the anonymous class that uses
\Winter\Storm\Support\Traits\Emitter so the test environment is initialized
before creating traitObject.

@github-actions

github-actions Bot commented Jan 3, 2026

Copy link
Copy Markdown

This pull request will be closed and archived in 3 days, as there has been no activity in the last 60 days.
If this is still being worked on, please respond and we will re-open this pull request.
If this pull request is critical to your business, consider joining the Premium Support Program where a Service Level Agreement is offered.

LukeTowers and others added 2 commits January 24, 2026 21:28
# Conflicts:
#	src/Foundation/Http/Middleware/CheckForMaintenanceMode.php
@github-actions

Copy link
Copy Markdown

This pull request will be closed and archived in 3 days, as there has been no activity in the last 60 days.
If this is still being worked on, please respond and we will re-open this pull request.
If this pull request is critical to your business, consider joining the Premium Support Program where a Service Level Agreement is offered.

The compileChange override in vendor/winter/storm/src/Database/Schema/Grammars/SQLiteGrammar.php reads SQLite column type names as tinyint (from SQLite metadata for boolean columns), then calls getType() which looks for ->typeTinyint(... — but only typeTinyInteger exists in the base grammar file Illuminate\Database\Schema\Grammars\SQLiteGrammar.

This should resolve migration issues others might hit when upgrading to 1.3
austinderrick added a commit to austinderrick/storm that referenced this pull request Jun 1, 2026
Builds on the Laravel 12 work (wintercms#207). Bumps to laravel/framework ^13,
PHP ^8.3, tinker ^3, carbon ^3.8.4, symfony ^7.4|^8.0, testbench ^11
(PHPUnit kept at ^11.5.50 pending phpunit 12 support in test helpers).

Code: defer ArraySource datasource setup out of the model boot cycle
(L13 forbids instantiating a model while booting); restate Builder<Model>
param on HasRelationships relation factories; fix Preferences::findRecord()
scope typing; drop the unsupported 2nd arg to DownCommand option('status');
update schedule:list invokable-class test expectation; prune stale phpstan
baseline entries; CI matrices to PHP 8.3/8.4.
austinderrick added a commit to austinderrick/storm that referenced this pull request Jun 2, 2026
Builds on the Laravel 12 work (wintercms#207). Bumps to laravel/framework ^13,
PHP ^8.3, tinker ^3, carbon ^3.8.4, symfony ^7.4|^8.0, testbench ^11
(PHPUnit kept at ^11.5.50 pending phpunit 12 support in test helpers).

Code: defer ArraySource datasource setup out of the model boot cycle
(L13 forbids instantiating a model while booting); restate Builder<Model>
param on HasRelationships relation factories; fix Preferences::findRecord()
scope typing; drop the unsupported 2nd arg to DownCommand option('status');
update schedule:list invokable-class test expectation; prune stale phpstan
baseline entries; CI matrices to PHP 8.3/8.4.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Database/Schema/Grammars/SQLiteGrammar.php (2)

151-155: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not strip all apostrophes from string defaults.

Line 154 removes every single quote, so defaults like 'O''Reilly' are corrupted to OReilly before recompilation.

Suggested fix
     public function getDefaultValue($value)
     {
-        if (is_string($value)) {
-            $value = preg_replace('#\'#', '', $value);
+        if (is_string($value) && preg_match("/^'(.*)'$/s", $value, $matches) === 1) {
+            // Remove only outer SQL string quotes and unescape doubled apostrophes.
+            $value = str_replace("''", "'", $matches[1]);
         }
 
         return parent::getDefaultValue($value);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Schema/Grammars/SQLiteGrammar.php` around lines 151 - 155, The
getDefaultValue method currently strips every apostrophe from string defaults
(corrupting values like 'O''Reilly'); change it to only remove surrounding
single quotes and unescape SQL doubled quotes instead of removing all
apostrophes: detect if $value starts and ends with a single quote, then strip
the outer quotes and replace doubled single-quotes ('' -> ') (e.g. via
trim($value, "'") and str_replace("''", "'", $value)) so internal apostrophes
are preserved; update getDefaultValue accordingly.

49-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve falsy defaults during column rebuilds.

Line 49 drops valid defaults like '0' or '' because it uses a truthy check. That changes schema semantics during change().

Suggested fix
-                        'default' => $column['default'] ? new Expression($column['default']) : null,
+                        'default' => $column['default'] !== null ? new Expression($column['default']) : null,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Database/Schema/Grammars/SQLiteGrammar.php` at line 49, The current
truthy check for $column['default'] in SQLiteGrammar.php drops valid falsy
defaults like '0' or '' during column rebuilds; change the condition to
explicitly preserve non-null defaults (e.g., check that the 'default' key exists
and is not null—using array_key_exists('default',$column) && $column['default']
!== null or isset($column['default']) && $column['default'] !== null) so that
$column['default'] values like '0' or '' are passed to new Expression(...)
instead of being treated as absent; update the line constructing the 'default'
entry accordingly within the code that rebuilds columns in class SQLiteGrammar.
🧹 Nitpick comments (2)
tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php (2)

84-84: ⚡ Quick win

Avoid hard-coding tinyint mapping check to statement index 0.

Line 84 is brittle if extra SQL (e.g., pragma toggles) is emitted before the rebuild statement.

Suggested stabilization
         // compileChange maps 'tinyint' (SQLite's introspected type_name) to 'integer'
         $this->assertNotEmpty($statements);
-        $this->assertStringContainsString('"is_active" integer', $statements[0]);
+        $this->assertStringContainsString('"is_active" integer', implode('; ', $statements));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php` at line 84, The
test currently asserts the tinyint mapping using a fixed index ($statements[0])
which is brittle; update the assertion in SQLiteSchemaGrammarTest to check that
any element of the $statements array contains the string '"is_active" integer'
(e.g., use an assertion that searches through $statements or uses something like
array_filter/any to verify presence) instead of indexing into $statements[0], so
the test passes even if extra SQL (pragma, etc.) appears before the rebuild
statement.

33-33: ⚡ Quick win

Strengthen nullable-change assertion to detect regressions.

Line 33 currently passes even if not null is still present; it only checks a broad substring.

Suggested test hardening
         $statements = $this->runBlueprint($changedBlueprint);
         $this->assertStringContainsString('"name" varchar', $statements[0]);
+        $this->assertStringNotContainsString('"name" varchar not null', $statements[0]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php` at line 33,
Replace the weak substring check on $statements[0] with a stronger assertion
that ensures the column definition for "name" is varchar and does not include
NOT NULL; for example, keep the positive check
(assertStringContainsString('"name" varchar', $statements[0])) and add
assertStringNotContainsString('not null', $statements[0]) or use
assertMatchesRegularExpression to assert the "name" column definition does not
contain 'not null' (target the $statements[0] variable and the existing
assertion call).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/Database/Schema/Grammars/SQLiteGrammar.php`:
- Around line 151-155: The getDefaultValue method currently strips every
apostrophe from string defaults (corrupting values like 'O''Reilly'); change it
to only remove surrounding single quotes and unescape SQL doubled quotes instead
of removing all apostrophes: detect if $value starts and ends with a single
quote, then strip the outer quotes and replace doubled single-quotes ('' -> ')
(e.g. via trim($value, "'") and str_replace("''", "'", $value)) so internal
apostrophes are preserved; update getDefaultValue accordingly.
- Line 49: The current truthy check for $column['default'] in SQLiteGrammar.php
drops valid falsy defaults like '0' or '' during column rebuilds; change the
condition to explicitly preserve non-null defaults (e.g., check that the
'default' key exists and is not null—using array_key_exists('default',$column)
&& $column['default'] !== null or isset($column['default']) &&
$column['default'] !== null) so that $column['default'] values like '0' or ''
are passed to new Expression(...) instead of being treated as absent; update the
line constructing the 'default' entry accordingly within the code that rebuilds
columns in class SQLiteGrammar.

---

Nitpick comments:
In `@tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php`:
- Line 84: The test currently asserts the tinyint mapping using a fixed index
($statements[0]) which is brittle; update the assertion in
SQLiteSchemaGrammarTest to check that any element of the $statements array
contains the string '"is_active" integer' (e.g., use an assertion that searches
through $statements or uses something like array_filter/any to verify presence)
instead of indexing into $statements[0], so the test passes even if extra SQL
(pragma, etc.) appears before the rebuild statement.
- Line 33: Replace the weak substring check on $statements[0] with a stronger
assertion that ensures the column definition for "name" is varchar and does not
include NOT NULL; for example, keep the positive check
(assertStringContainsString('"name" varchar', $statements[0])) and add
assertStringNotContainsString('not null', $statements[0]) or use
assertMatchesRegularExpression to assert the "name" column definition does not
contain 'not null' (target the $statements[0] variable and the existing
assertion call).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1904f6e0-e6f4-496c-9586-2ab50795212c

📥 Commits

Reviewing files that changed from the base of the PR and between 0b3ca7f and 179ef50.

📒 Files selected for processing (8)
  • src/Config/Repository.php
  • src/Console/Traits/ProcessesQuery.php
  • src/Database/Schema/Grammars/SQLiteGrammar.php
  • src/Halcyon/Processors/Processor.php
  • src/Html/FormBuilder.php
  • src/Support/Facades/Form.php
  • tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php
  • tests/Scheduling/ScheduleListCommandTest.php
✅ Files skipped from review due to trivial changes (1)
  • src/Support/Facades/Form.php

lex0r and others added 2 commits July 22, 2026 15:35
Fixes wintercms/winter#1500

Co-authored-by: Alex Orosciuc <alex.orosciuc@gmail.com>
# Conflicts:
#	.github/workflows/tests.yml
#	composer.json
#	phpstan.neon
#	src/Config/ConfigWriter.php
#	src/Config/Repository.php
#	src/Database/Connections/MySqlConnection.php
#	src/Database/Connections/PostgresConnection.php
#	src/Database/Connections/SQLiteConnection.php
#	src/Database/Connections/SqlServerConnection.php
#	tests/Database/Traits/HasSortableRelationsTest.php
#	tests/Parse/ArrayFileTest.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
tests/Parse/Assetic/LessCompilerTest.php (2)

112-114: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the class closing-brace placement.

The supplied Code Quality / PHP job fails at Line 114. Remove the blank line before the class closing brace so the brace follows the class body immediately.

Minimal formatting fix
         return $asset->getContent();
     }
-
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Parse/Assetic/LessCompilerTest.php` around lines 112 - 114, In the test
class shown in the diff, remove the blank line immediately before the class
closing brace so the brace directly follows the class body.

Source: Pipeline failures


29-32: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Fail before using paths derived from a failed temporary-directory setup.

These setUp methods ignore mkdir() and PathResolver::resolve() failures. A false realPath is later cast to 0 and used in path/file operations outside the intended temp tree.

  • tests/Parse/Assetic/LessCompilerTest.php#L29-L32: check PathResolver::resolve() before writing $this->tmpReal . '/secret.env'.
  • tests/Parse/Assetic/JavascriptImporterTest.php#L25-L37: check mkdir() and PathResolver::resolve() before combine() uses $this->tmpReal.
  • tests/Parse/Assetic/LessImportResolverTest.php#L30-L30: check PathResolver::resolve() before creating paths such as $this->tmpReal . '/inside/main.less'.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Parse/Assetic/LessCompilerTest.php` around lines 29 - 32, Make the
affected test setUp methods fail immediately when temporary-directory setup is
unsuccessful: in tests/Parse/Assetic/LessCompilerTest.php lines 29-32, validate
PathResolver::resolve() before writing secret.env; in
tests/Parse/Assetic/JavascriptImporterTest.php lines 25-37, validate mkdir() and
PathResolver::resolve() before combine() uses tmpReal; and in
tests/Parse/Assetic/LessImportResolverTest.php line 30, validate
PathResolver::resolve() before constructing paths under tmpReal.
tests/Parse/Assetic/LessImportResolverTest.php (1)

75-82: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add symlink-escape coverage for LessImportResolverTest.

LessImportResolver::makeResolver() resolves symlink targets with realpath(), but the test class has no symlink fixture. Add a coverage case where a symlink inside the context directory or an allowed root points outside that tree and the resolver returns LessImportResolver::SENTINEL_PATH. Skip only when symlink creation is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Parse/Assetic/LessImportResolverTest.php` around lines 75 - 82, Add a
symlink-escape test alongside
testBlocksRelativeTraversalEscapeOutsideContextDir, creating a symlink within
the resolver context or allowed root that targets a file or directory outside
it, then assert resolving through that link returns
LessImportResolver::SENTINEL_PATH. Use the existing temporary-fixture setup and
skip only when symlink creation is unavailable.

Source: Coding guidelines

🧹 Nitpick comments (2)
tests/Mail/MailParserTest.php (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use plain PHPUnit bases for the pure helper tests.

All four classes use \Winter\Storm\Tests\TestCase, although their visible code does not use Laravel container state or application fixtures. Use \PHPUnit\Framework\TestCase at each site after verifying that no inherited Storm setup is required.

  • tests/Mail/MailParserTest.php#L5-L5: change the parent class for the parser-only test.
  • tests/Parse/Assetic/JavascriptImporterTest.php#L12-L12: change the parent class for the importer-only test.
  • tests/Parse/Assetic/LessCompilerTest.php#L14-L14: change the parent class for the compiler-only test.
  • tests/Parse/Assetic/LessImportResolverTest.php#L10-L10: change the parent class for the resolver-only test.

As per coding guidelines, use TestCase for tests requiring the Laravel container and plain unit tests for pure helper classes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Mail/MailParserTest.php` at line 5, Use PHPUnit\Framework\TestCase
instead of Winter\Storm\Tests\TestCase for the pure helper tests, after
confirming no inherited Storm setup is required: update MailParserTest in
tests/Mail/MailParserTest.php at lines 5-5, JavascriptImporterTest in
tests/Parse/Assetic/JavascriptImporterTest.php at lines 12-12, LessCompilerTest
in tests/Parse/Assetic/LessCompilerTest.php at lines 14-14, and
LessImportResolverTest in tests/Parse/Assetic/LessImportResolverTest.php at
lines 10-10.

Source: Coding guidelines

tests/Parse/Assetic/JavascriptImporterTest.php (1)

24-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use Laravel's random helper for temporary artifact names.

The same bin2hex(random_bytes(4)) pattern appears in all three fixture setups. Replace it with Str::random() or Str::uuid().

  • tests/Parse/Assetic/JavascriptImporterTest.php#L24-L24: replace the random suffix used for $this->tmpRoot.
  • tests/Parse/Assetic/LessCompilerTest.php#L26-L26: replace the random suffix used for $this->tmpRoot.
  • tests/Parse/Assetic/LessImportResolverTest.php#L22-L22: replace the random suffix used for $this->tmpRoot.

As per coding guidelines, use Laravel Str::random() or Str::uuid() for random temporary-file names; do not manually format UUIDs from random bytes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Parse/Assetic/JavascriptImporterTest.php` at line 24, Replace the
manual bin2hex(random_bytes(4)) temporary-name suffix in the fixture setups at
tests/Parse/Assetic/JavascriptImporterTest.php:24,
tests/Parse/Assetic/LessCompilerTest.php:26, and
tests/Parse/Assetic/LessImportResolverTest.php:22 with Laravel’s Str::random()
or Str::uuid(), adding the necessary Str imports while preserving the existing
$this->tmpRoot prefix.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tests/Parse/Assetic/LessCompilerTest.php`:
- Around line 112-114: In the test class shown in the diff, remove the blank
line immediately before the class closing brace so the brace directly follows
the class body.
- Around line 29-32: Make the affected test setUp methods fail immediately when
temporary-directory setup is unsuccessful: in
tests/Parse/Assetic/LessCompilerTest.php lines 29-32, validate
PathResolver::resolve() before writing secret.env; in
tests/Parse/Assetic/JavascriptImporterTest.php lines 25-37, validate mkdir() and
PathResolver::resolve() before combine() uses tmpReal; and in
tests/Parse/Assetic/LessImportResolverTest.php line 30, validate
PathResolver::resolve() before constructing paths under tmpReal.

In `@tests/Parse/Assetic/LessImportResolverTest.php`:
- Around line 75-82: Add a symlink-escape test alongside
testBlocksRelativeTraversalEscapeOutsideContextDir, creating a symlink within
the resolver context or allowed root that targets a file or directory outside
it, then assert resolving through that link returns
LessImportResolver::SENTINEL_PATH. Use the existing temporary-fixture setup and
skip only when symlink creation is unavailable.

---

Nitpick comments:
In `@tests/Mail/MailParserTest.php`:
- Line 5: Use PHPUnit\Framework\TestCase instead of Winter\Storm\Tests\TestCase
for the pure helper tests, after confirming no inherited Storm setup is
required: update MailParserTest in tests/Mail/MailParserTest.php at lines 5-5,
JavascriptImporterTest in tests/Parse/Assetic/JavascriptImporterTest.php at
lines 12-12, LessCompilerTest in tests/Parse/Assetic/LessCompilerTest.php at
lines 14-14, and LessImportResolverTest in
tests/Parse/Assetic/LessImportResolverTest.php at lines 10-10.

In `@tests/Parse/Assetic/JavascriptImporterTest.php`:
- Line 24: Replace the manual bin2hex(random_bytes(4)) temporary-name suffix in
the fixture setups at tests/Parse/Assetic/JavascriptImporterTest.php:24,
tests/Parse/Assetic/LessCompilerTest.php:26, and
tests/Parse/Assetic/LessImportResolverTest.php:22 with Laravel’s Str::random()
or Str::uuid(), adding the necessary Str imports while preserving the existing
$this->tmpRoot prefix.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b29e1c6d-257c-4e36-937f-63112f8f064a

📥 Commits

Reviewing files that changed from the base of the PR and between 179ef50 and 7d17e41.

📒 Files selected for processing (19)
  • .github/workflows/code-analysis.yaml
  • .github/workflows/tests.yml
  • .gitignore
  • composer.json
  • phpstan.neon
  • src/Config/ConfigWriter.php
  • src/Console/Traits/HandlesCleanup.php
  • src/Database/Builder.php
  • src/Database/Model.php
  • src/Extension/ExtendableTrait.php
  • src/Halcyon/Datasource/DbDatasource.php
  • src/Network/Http.php
  • tests/Database/ModelTest.php
  • tests/Filesystem/PathResolverTest.php
  • tests/Mail/MailParserTest.php
  • tests/Parse/Assetic/JavascriptImporterTest.php
  • tests/Parse/Assetic/LessCompilerTest.php
  • tests/Parse/Assetic/LessImportResolverTest.php
  • tests/Parse/IniTest.php
🚧 Files skipped from review as they are similar to previous changes (12)
  • tests/Filesystem/PathResolverTest.php
  • src/Console/Traits/HandlesCleanup.php
  • src/Network/Http.php
  • src/Extension/ExtendableTrait.php
  • src/Config/ConfigWriter.php
  • tests/Parse/IniTest.php
  • .gitignore
  • .github/workflows/code-analysis.yaml
  • phpstan.neon
  • .github/workflows/tests.yml
  • src/Database/Builder.php
  • src/Database/Model.php

Remove the getCountForPagination/runPaginationCountQuery override, which
was added in #39 to support having() in count queries -- Laravel's base
builder now handles that natively. Since 0a6b24e ("Laravel 10 changes")
the override passed the count subquery builder to from() (which registers
its bindings) and also called mergeBindings() on it, binding the WHERE
values twice and throwing SQLSTATE[HY093] "Invalid parameter number" on
any grouped paginate() with bound conditions. Falling back to the base
implementation fixes this and restores union support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/Database/QueryBuilderPaginateTest.php`:
- Around line 48-52: Update the query builder chain creating $paginator to call
select('group_id') before groupBy('group_id'), ensuring paginate(2) selects only
the grouped column while preserving the existing filter, ordering, and
pagination behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c5cf0497-1ccc-4736-9f7e-e68b777056c9

📥 Commits

Reviewing files that changed from the base of the PR and between 7d17e41 and 1f2ff06.

📒 Files selected for processing (2)
  • src/Database/QueryBuilder.php
  • tests/Database/QueryBuilderPaginateTest.php
💤 Files with no reviewable changes (1)
  • src/Database/QueryBuilder.php

Comment on lines +48 to +52
$paginator = DB::table('pagination_test')
->whereRaw('(group_id = ? OR group_id > ?)', [1, 0])
->groupBy('group_id')
->orderBy('group_id')
->paginate(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(Tests?|tests|database|migrations|src/Database/Builder.php|DbTestCase\.php)' || true

echo
echo "== target test context =="
target="$(git ls-files | rg '(^|/)QueryBuilderPaginateTest\.php$' | head -n1 || true)"
if [ -n "${target:-}" ]; then
  wc -l "$target"
  sed -n '1,140p' "$target" | cat -n
fi

echo
echo "== builder context =="
builder="$(git ls-files | rg '(^|/)Builder\.php$' | grep -F 'src/Database/Builder.php' | head -n1 || true)"
if [ -n "${builder:-}" ]; then
  wc -l "$builder"
  sed -n '120,180p' "$builder" | cat -n
fi

echo
echo "== pagination_test migrations/fixtures =="
rg -n "pagination_test|CREATE TABLE|table\('pagination_test'\)" -S .

Repository: wintercms/storm

Length of output: 21150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== check select() behavior in query builder =="
python3 - <<'PY'
import re, pathlib
for p in pathlib.Path('.').rglob('*.php'):
    if 'vendor' in p.parts or 'node_modules' in p.parts:
        continue
    s = p.read_text(errors='ignore')
    if re.search(r'function select\s*\(|function get\s*\(', s):
        print(p)
PY

Repository: wintercms/storm

Length of output: 826


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate query builder implementation files =="
git ls-files | rg '(^|/)([A-Z].*Builder\.php|QueryBuilder|Base\.php|Grammar\.php|Schema).*\.php$' | sed -n '1,200p'

echo
echo "== precise builder method definitions =="
rg -n "function select\s*\(|function get\s*\(|function getColumns|SELECT \*" -S --glob '*.php' | sed -n '1,220p'

Repository: wintercms/storm

Length of output: 958


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(Tests?|tests|database|migrations|src/Database/Builder.php|DbTestCase\.php)' || true

echo
echo "== target test context =="
target="$(git ls-widgets | rg '(^|/)QueryBuilderPaginateTest\.php$' | head -n1 || true)"
if [ -n "${target:-}" ]; then
  wc -l "$target"
  sed -n '1,140p' "$target" | cat -n
fi

echo
echo "== builder context =="
builder="$(git ls-files | rg '(^|/)Builder\.php$' | grep -F 'src/Database/Builder.php' | head -n1 || true)"
if [ -n "${builder:-}" ]; then
  wc -l "$builder"
  sed -n '120,180p' "$builder" | cat -n
fi

echo
echo "== pagination_test migrations/fixtures =="
rg -n "pagination_test|CREATE TABLE|table\('pagination_test'\)" -S .

Repository: wintercms/storm

Length of output: 17905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== DbTestCase =="
wc -l tests/DbTestCase.php
sed -n '1,220p' tests/DbTestCase.php | cat -n

echo
echo "== QueryBuilder select/get/compile relevant methods =="
wc -l src/Database/Builder.php src/Database/QueryBuilder.php
rg -n "class Builder|class QueryBuilder|function select|function get\(|function getCountForPagination|pagination" src/Database src -g '*.php' | sed -n '1,240p'
sed -n '1,140p' src/Database/QueryBuilder.php | cat -n
sed -n '140,280p' src/Database/QueryBuilder.php | cat -n
sed -n '280,500p' src/Database/Builder.php | cat -n

echo
echo "== composer Laravel version =="
if [ -f composer.json ]; then jq '.require,."require-dev"' composer.json; fi

Repository: wintercms/storm

Length of output: 19147


🌐 Web query:

Laravel QueryBuilder paginate default columns SELECT * select('group_id) groupBy Postgres MySQL ONLY_FULL_GROUP_BY

💡 Result:

In Laravel, using the paginate() method with a groupBy() clause is a complex operation that often leads to errors due to how Laravel calculates the total record count for pagination [1][2][3]. When you call paginate(), Laravel executes two queries: the main query to fetch the items for the current page and a separate "count" query to determine the total number of records [4][5]. When a groupBy() is present, the default count query (often SELECT count(*)) can return incorrect results or trigger database errors because it tries to group the count itself [3]. Key Technical Considerations: 1. SQL Compliance (ONLY_FULL_GROUP_BY): PostgreSQL and modern MySQL (with ONLY_FULL_GROUP_BY enabled) strictly require that all columns in your SELECT list are either part of the GROUP BY clause or used within an aggregate function (e.g., SUM(), MIN(), MAX(), ANY_VALUE()) [6][7][8]. If you use select('*') while grouping, these databases will reject the query because non-aggregated columns cannot be deterministically included in the result [6][7]. 2. Laravel Pagination Strategy: To address the challenges of paginating grouped queries, consider these approaches: - Refine the Select Clause: Only select columns that are explicitly in your GROUP BY clause or properly aggregated [7]. - Manual Pagination: If standard paginate() fails due to the complexity of your grouping (especially with JOINs), you may need to perform a manual query, count the results, and use Illuminate\Pagination\LengthAwarePaginator to create the paginator instance manually [1][2]. - Use simplePaginate(): If you do not need the total page count, simplePaginate() may be more efficient as it avoids the complex count query, though it still requires valid SQL syntax for grouping [9]. 3. Common Pitfalls: - Duplicate Column Names: When using join() with select('*') alongside groupBy(), you may encounter "Duplicate column name" errors during the count query execution because Laravel creates a subquery wrapper that can conflict with existing column names [5]. - Performance: Using groupBy() on large datasets causes the paginator to process significantly more data than a standard query, often resulting in slow page loads [2]. To resolve these errors, ensure your SQL is compliant with your database's strict mode and adjust your select() clause to avoid selecting ambiguous, non-aggregated columns [6][7].

Citations:


Select group_id before grouping.

paginate(2) calls get(['*']), which makes this grouped query select the non-grouped id and name columns. On PostgreSQL or MySQL with ONLY_FULL_GROUP_BY, that fails before pagination is counted. Add ->select('group_id') before ->groupBy('group_id').

Proposed fix
         $paginator = DB::table('pagination_test')
+            ->select('group_id')
             ->whereRaw('(group_id = ? OR group_id > ?)', [1, 0])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$paginator = DB::table('pagination_test')
->whereRaw('(group_id = ? OR group_id > ?)', [1, 0])
->groupBy('group_id')
->orderBy('group_id')
->paginate(2);
$paginator = DB::table('pagination_test')
->select('group_id')
->whereRaw('(group_id = ? OR group_id > ?)', [1, 0])
->groupBy('group_id')
->orderBy('group_id')
->paginate(2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Database/QueryBuilderPaginateTest.php` around lines 48 - 52, Update the
query builder chain creating $paginator to call select('group_id') before
groupBy('group_id'), ensuring paginate(2) selects only the grouped column while
preserving the existing filter, ordering, and pagination behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sh-prone rebuild (#239)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
austinderrick added a commit to austinderrick/storm that referenced this pull request Aug 19, 2026
Builds on the Laravel 12 work (wintercms#207). Bumps to laravel/framework ^13,
PHP ^8.3, tinker ^3, carbon ^3.8.4, symfony ^7.4|^8.0, testbench ^11
(PHPUnit kept at ^11.5.50 pending phpunit 12 support in test helpers).

Code: defer ArraySource datasource setup out of the model boot cycle
(L13 forbids instantiating a model while booting); restate Builder<Model>
param on HasRelationships relation factories; fix Preferences::findRecord()
scope typing; drop the unsupported 2nd arg to DownCommand option('status');
update schedule:list invokable-class test expectation; prune stale phpstan
baseline entries; CI matrices to PHP 8.3/8.4.
# Conflicts:
#	composer.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement PRs that implement a new feature or substantial change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants