Laravel Guard bypass in Eloquent models
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
In laravel releases before 6.18.34 and 7.23.2. It was possible to mass assign Eloquent attributes that included the model's table name: ``` $model->fill(['users.name' => 'Taylor']); ``` When doing so, Eloquent would remove the table name from the attribute for you. This was a "convenience" feature of Eloquent and was not documented. However, when paired with validation, this can lead to unexpected and unvalidated values being saved to the database. For this reason, we have removed the automatic stripping of table names from mass-asignment operations so that the attributes go through the typical "fillable" / "guarded" logic. Any attributes containing table names that are not explicitly declared as fillable will be discarded. This security release will be a breaking change for applications that were relying on the undocumented table name stripping during mass assignment. Since this feature was relatively unknown and undocumented, we expect the vast majority of Laravel applications to be able to upgrade without issues.
The fix
Release delta 6.0.0 → 6.18.34 (contains the fix)
tests/Database/DatabaseMySqlSchemaGrammarTest.php+4 −4
@@ -467,14 +467,14 @@ public function testAddingString()$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());$this->assertCount(1, $statements);-$this->assertSame('alter table `users` add `foo` varchar(100) null default \'bar\'', $statements[0]);+$this->assertSame('alter table `users` add `foo` varchar(100) null default (\'bar\')', $statements[0]);$blueprint = new Blueprint('users');$blueprint->string('foo', 100)->nullable()->default(new Expression('CURRENT TIMESTAMP'));$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());$this->assertCount(1, $statements);-$this->assertSame('alter table `users` add `foo` varchar(100) null default CURRENT TIMESTAMP', $statements[0]);+$this->assertSame('alter table `users` add `foo` varchar(100) null default (CURRENT TIMESTAMP)', $statements[0]);}public function testAddingText()@@ -771,7 +771,7 @@ public function testAddingTimestampWithDefault()$blueprint->timestamp('created_at')->default('2015-07-22 11:43:17');$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());$this->assertCount(1, $statements);-$this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]);+$this->assertSame("alter table `users` add `created_at` timestamp not null default ('2015-07-22 11:43:17')", $statements[0]);}public function testAddingTimestampTz()@@ -798,7 +798,7 @@ public function testAddingTimeStampTzWithDefault()$blueprint->timestampTz('created_at')->default('2015-07-22 11:43:17');$statements = $blueprint->toSql($this->getConnection(), $this->getGrammar());$this->assertCount(1, $statements);-$this->assertSame("alter table `users` add `created_at` timestamp not null default '2015-07-22 11:43:17'", $statements[0]);+$this->assertSame("alter table `users` add `created_at` timestamp not null default ('2015-07-22 11:43:17')", $statements[0]);}public function testAddingTimestamps()src/Illuminate/Foundation/Console/stubs/policy.stub | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
tests/Integration/Migration/fixtures/2014_10_12_000000_create_people_table.php+3 −5
@@ -1,12 +1,10 @@<?php-namespace Illuminate\Tests\Integration\Migration\fixtures;-use Illuminate\Support\Facades\Schema;use Illuminate\Database\Schema\Blueprint;use Illuminate\Database\Migrations\Migration;-class CreateMembersTable extends Migration+class CreatePeopleTable extends Migration{/*** Run the migrations.@@ -15,7 +13,7 @@ class CreateMembersTable extends Migration*/public function up(){-Schema::create('members', function (Blueprint $table) {+Schema::create('people', function (Blueprint $table) {$table->increments('id');$table->string('name');$table->string('email')->unique();@@ -32,6 +30,6 @@ public function up()*/public function down(){-Schema::drop('members');+Schema::drop('people');}}src/Illuminate/Foundation/Application.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Database/Eloquent/FactoryBuilder.php+1 −4
@@ -344,10 +344,7 @@ protected function stateAttributes($state, array $attributes)return $stateAttributes;}-return call_user_func(-$stateAttributes,-$this->faker, $attributes-);+return $stateAttributes($this->faker, $attributes);}/**
src/Illuminate/Auth/SessionGuard.php+9 −9
@@ -2,19 +2,19 @@namespace Illuminate\Auth;-use RuntimeException;-use Illuminate\Support\Str;-use Illuminate\Support\Facades\Hash;-use Illuminate\Support\Traits\Macroable;-use Illuminate\Contracts\Session\Session;-use Illuminate\Contracts\Auth\UserProvider;-use Illuminate\Contracts\Events\Dispatcher;+use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;use Illuminate\Contracts\Auth\StatefulGuard;-use Symfony\Component\HttpFoundation\Request;use Illuminate\Contracts\Auth\SupportsBasicAuth;+use Illuminate\Contracts\Auth\UserProvider;use Illuminate\Contracts\Cookie\QueueingFactory as CookieJar;+use Illuminate\Contracts\Events\Dispatcher;+use Illuminate\Contracts\Session\Session;+use Illuminate\Support\Facades\Hash;+use Illuminate\Support\Str;+use Illuminate\Support\Traits\Macroable;+use RuntimeException;+use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;-use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;class SessionGuard implements StatefulGuard, SupportsBasicAuth{
src/Illuminate/Support/Facades/Facade.php+3 −1
@@ -172,7 +172,9 @@ protected static function resolveFacadeInstance($name)return static::$resolvedInstance[$name];}-return static::$resolvedInstance[$name] = static::$app[$name];+if (static::$app) {+return static::$resolvedInstance[$name] = static::$app[$name];+}}/**
src/Illuminate/Database/Concerns/BuildsQueries.php+1 −1
@@ -136,7 +136,7 @@ public function eachById(callable $callback, $count = 1000, $column = null, $ali* Execute the query and get the first result.** @param array $columns-* @return \Illuminate\Database\Eloquent\Model|object|static|null+* @return \Illuminate\Database\Eloquent\Model|object|null*/public function first($columns = ['*']){registered method" (#29875)src/Illuminate/Foundation/Auth/RegistersUsers.php | 8 ++++----1 file changed, 4 insertions(+), 4 deletions(-)
src/Illuminate/Database/Concerns/BuildsQueries.php+1 −1
@@ -136,7 +136,7 @@ public function eachById(callable $callback, $count = 1000, $column = null, $ali* Execute the query and get the first result.** @param array $columns-* @return \Illuminate\Database\Eloquent\Model|object|null+* @return \Illuminate\Database\Eloquent\Model|object|static|null*/public function first($columns = ['*']){src/Illuminate/Foundation/Application.php | 28 ++++++++++-------------1 file changed, 12 insertions(+), 16 deletions(-)
src/Illuminate/Auth/Middleware/Authorize.php+1 −1
@@ -3,8 +3,8 @@namespace Illuminate\Auth\Middleware;use Closure;-use Illuminate\Database\Eloquent\Model;use Illuminate\Contracts\Auth\Access\Gate;+use Illuminate\Database\Eloquent\Model;class Authorize{
src/Illuminate/Foundation/Auth/RegistersUsers.php+4 −4
@@ -30,7 +30,9 @@ public function register(Request $request){$this->validator($request->all())->validate();-$user = $this->create($request->all());+event(new Registered($user = $this->create($request->all())));++$this->guard()->login($user);return $this->registered($request, $user)?: redirect($this->redirectPath());@@ -55,8 +57,6 @@ protected function guard()*/protected function registered(Request $request, $user){-event(new Registered($user));--$this->guard()->login($user);+//}}to registered method" (#29875)" (#29879)src/Illuminate/Foundation/Auth/RegistersUsers.php | 8 ++++----1 file changed, 4 insertions(+), 4 deletions(-)
src/Illuminate/Foundation/Auth/RegistersUsers.php+4 −4
@@ -30,9 +30,7 @@ public function register(Request $request){$this->validator($request->all())->validate();-event(new Registered($user = $this->create($request->all())));--$this->guard()->login($user);+$user = $this->create($request->all());return $this->registered($request, $user)?: redirect($this->redirectPath());@@ -57,6 +55,8 @@ protected function guard()*/protected function registered(Request $request, $user){-//+event(new Registered($user));++$this->guard()->login($user);}}and login to registered method" (#29875)" (#29879)" (#29880)src/Illuminate/Foundation/Auth/RegistersUsers.php | 8 ++++----1 file changed, 4 insertions(+), 4 deletions(-)
src/Illuminate/Foundation/Auth/RegistersUsers.php+4 −4
@@ -30,7 +30,9 @@ public function register(Request $request){$this->validator($request->all())->validate();-$user = $this->create($request->all());+event(new Registered($user = $this->create($request->all())));++$this->guard()->login($user);return $this->registered($request, $user)?: redirect($this->redirectPath());@@ -55,8 +57,6 @@ protected function guard()*/protected function registered(Request $request, $user){-event(new Registered($user));--$this->guard()->login($user);+//}}src/Illuminate/Foundation/Application.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Auth/EloquentUserProvider.php+3 −3
@@ -2,11 +2,11 @@namespace Illuminate\Auth;-use Illuminate\Support\Str;+use Illuminate\Contracts\Auth\Authenticatable as UserContract;use Illuminate\Contracts\Auth\UserProvider;-use Illuminate\Contracts\Support\Arrayable;use Illuminate\Contracts\Hashing\Hasher as HasherContract;-use Illuminate\Contracts\Auth\Authenticatable as UserContract;+use Illuminate\Contracts\Support\Arrayable;+use Illuminate\Support\Str;class EloquentUserProvider implements UserProvider{
tests/Integration/Migration/MigratorTest.php+1 −1
@@ -27,7 +27,7 @@ public function test_dont_display_output_when_output_object_is_not_available()$migrator->run([__DIR__.'/fixtures']);-$this->assertTrue($this->tableExists('members'));+$this->assertTrue($this->tableExists('people'));}private function tableExists($table): bool
src/Illuminate/Routing/UrlGenerator.php+3 −2
@@ -11,6 +11,7 @@use Illuminate\Support\Traits\Macroable;use Illuminate\Support\InteractsWithTime;use Illuminate\Contracts\Routing\UrlRoutable;+use Symfony\Component\Routing\Exception\RouteNotFoundException;use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract;class UrlGenerator implements UrlGeneratorContract@@ -377,7 +378,7 @@ public function hasValidSignature(Request $request, $absolute = true)* @param bool $absolute* @return string*-* @throws \InvalidArgumentException+* @throws \Symfony\Component\Routing\Exception\RouteNotFoundException*/public function route($name, $parameters = [], $absolute = true){@@ -385,7 +386,7 @@ public function route($name, $parameters = [], $absolute = true)return $this->toRoute($route, $parameters, $absolute);}-throw new InvalidArgumentException("Route [{$name}] not defined.");+throw new RouteNotFoundException("Route [{$name}] not defined.");}/**
src/Illuminate/Database/Eloquent/Model.php+1 −1
@@ -278,7 +278,7 @@ public static function withoutTouchingOn(array $models, callable $callback)static::$ignoreOnTouch = array_values(array_merge(static::$ignoreOnTouch, $models));try {-call_user_func($callback);+$callback();} finally {static::$ignoreOnTouch = array_values(array_diff(static::$ignoreOnTouch, $models));}
Release delta 7.0.0 → 7.23.2 (contains the fix)
src/Illuminate/Routing/CompiledRouteCollection.php+43 −52
@@ -5,6 +5,8 @@use Illuminate\Container\Container;use Illuminate\Http\Request;use Illuminate\Support\Collection;+use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;use Symfony\Component\Routing\Exception\MethodNotAllowedException;use Symfony\Component\Routing\Exception\ResourceNotFoundException;use Symfony\Component\Routing\Matcher\CompiledUrlMatcher;@@ -26,6 +28,13 @@ class CompiledRouteCollection extends AbstractRouteCollection*/protected $attributes = [];+/**+* An array of the routes that were added after loading the compiled routes.+*+* @var \Illuminate\Routing\RouteCollection|null+*/+protected $routes;+/*** The router instance used by the route.*@@ -51,6 +60,7 @@ public function __construct(array $compiled, array $attributes){$this->compiled = $compiled;$this->attributes = $attributes;+$this->routes = new RouteCollection;}/**@@ -61,21 +71,7 @@ public function __construct(array $compiled, array $attributes)*/public function add(Route $route){-$name = $route->getName() ?: $this->generateRouteName();--$this->attributes[$name] = [-'methods' => $route->methods(),-'uri' => $route->uri(),-'action' => $route->getAction() + ['as' => $name],-'fallback' => $route->isFallback,-'defaults' => $route->defaults,-'wheres' => $route->wheres,-'bindingFields' => $route->bindingFields(),-];--$this->compiled = [];--return $route;+return $this->routes->add($route);}/**@@ -108,41 +104,32 @@ public function refreshActionLookups()* @param \Illuminate\Http\Request $request* @return \Illuminate\Routing\Route*+* @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException*/public function match(Request $request){-if (empty($this->compiled) && $this->attributes) {-$this->recompileRoutes();-}--$route = null;-$matcher = new CompiledUrlMatcher($this->compiled, (new RequestContext)->fromRequest($request));+$route = null;+try {if ($result = $matcher->matchRequest($request)) {$route = $this->getByName($result['_route']);}} catch (ResourceNotFoundException | MethodNotAllowedException $e) {-//+try {+return $this->routes->match($request);+} catch (NotFoundHttpException | MethodNotAllowedHttpException $e) {+//+}}return $this->handleMatchedRoute($request, $route);}-/**-* Recompile the routes from the attributes array.-*-* @return void-*/-protected function recompileRoutes()-{-$this->compiled = $this->dumper()->getCompiledRoutes();-}-/*** Get routes from the collection by method.*@@ -162,7 +149,7 @@ public function get($method = null)*/public function hasNamedRoute($name){-return isset($this->attributes[$name]);+return isset($this->attributes[$name]) || $this->routes->hasNamedRoute($name);}/**@@ -173,7 +160,11 @@ public function hasNamedRoute($name)*/public function getByName($name){-return isset($this->attributes[$name]) ? $this->newRoute($this->attributes[$name]) : null;+if (isset($this->attributes[$name])) {+return $this->newRoute($this->attributes[$name]);+}++return $this->routes->getByName($name);}/**@@ -192,7 +183,11 @@ public function getByAction($action)return $attributes['action']['uses'] === $action;});-return $attributes ? $this->newRoute($attributes) : null;+if ($attributes) {+return $this->newRoute($attributes);+}++return $this->routes->getByAction($action);}/**@@ -202,7 +197,13 @@ public function getByAction($action)*/public function getRoutes(){-return $this->mapAttributesToRoutes()->values()->all();+return collect($this->attributes)+->map(function (array $attributes) {+return $this->newRoute($attributes);+})+->merge($this->routes->getRoutes())+->values()+->all();}/**@@ -212,7 +213,7 @@ public function getRoutes()*/public function getRoutesByMethod(){-return $this->mapAttributesToRoutes()+return collect($this->getRoutes())->groupBy(function (Route $route) {return $route->methods();})@@ -231,21 +232,11 @@ public function getRoutesByMethod()*/public function getRoutesByName(){-return $this->mapAttributesToRoutes()->keyBy(function (Route $route) {-return $route->getName();-})->all();-}--/**-* Get all of the routes in the collection.-*-* @return \Illuminate\Support\Collection-*/-public function mapAttributesToRoutes()-{-return collect($this->attributes)->map(function (array $attributes) {-return $this->newRoute($attributes);-});+return collect($this->getRoutes())+->keyBy(function (Route $route) {+return $route->getName();+})+->all();}/**
tests/Integration/Routing/CompiledRouteCollectionTest.php+109 −35
@@ -4,8 +4,8 @@use ArrayIterator;use Illuminate\Http\Request;-use Illuminate\Routing\CompiledRouteCollection;use Illuminate\Routing\Route;+use Illuminate\Routing\RouteCollection;use Illuminate\Support\Arr;use Illuminate\Tests\Integration\IntegrationTest;use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;@@ -14,7 +14,7 @@class CompiledRouteCollectionTest extends IntegrationTest{/**-* @var \Illuminate\Routing\CompiledRouteCollection+* @var \Illuminate\Routing\RouteCollection*/protected $routeCollection;@@ -29,9 +29,11 @@ protected function setUp(): void$this->router = $this->app['router'];-$this->routeCollection = (new CompiledRouteCollection([], []))-->setRouter($this->router)-->setContainer($this->app);+$this->routeCollection = new RouteCollection;++// $this->routeCollection = (new CompiledRouteCollection([], []))+// ->setRouter($this->router)+// ->setContainer($this->app);}protected function tearDown(): void@@ -42,21 +44,31 @@ protected function tearDown(): voidunset($this->router);}+/**+* @return \Illuminate\Routing\CompiledRouteCollection+*/+protected function collection()+{+return $this->routeCollection->toCompiledRouteCollection($this->router, $this->app);+}+public function testRouteCollectionCanAddRoute(){$this->routeCollection->add($this->newRoute('GET', 'foo', ['uses' => 'FooController@index','as' => 'foo_index',]));-$this->assertCount(1, $this->routeCollection);++$this->assertCount(1, $this->collection());}public function testRouteCollectionAddReturnsTheRoute(){-$outputRoute = $this->routeCollection->add($inputRoute = $this->newRoute('GET', 'foo', [+$outputRoute = $this->collection()->add($inputRoute = $this->newRoute('GET', 'foo', ['uses' => 'FooController@index','as' => 'foo_index',]));+$this->assertInstanceOf(Route::class, $outputRoute);$this->assertEquals($inputRoute, $outputRoute);}@@ -68,9 +80,11 @@ public function testRouteCollectionCanRetrieveByName()'as' => 'route_name',]));+$routes = $this->collection();+$this->assertSame('route_name', $routeIndex->getName());-$this->assertSame('route_name', $this->routeCollection->getByName('route_name')->getName());-$this->assertEquals($routeIndex, $this->routeCollection->getByName('route_name'));+$this->assertSame('route_name', $routes->getByName('route_name')->getName());+$this->assertEquals($routeIndex, $routes->getByName('route_name'));}public function testRouteCollectionCanRetrieveByAction()@@ -79,7 +93,7 @@ public function testRouteCollectionCanRetrieveByAction()'uses' => 'FooController@index',]));-$route = $this->routeCollection->getByAction('FooController@index');+$route = $this->collection()->getByAction('FooController@index');$this->assertSame($action, Arr::except($routeIndex->getAction(), 'as'));$this->assertSame($action, Arr::except($route->getAction(), 'as'));@@ -91,52 +105,68 @@ public function testRouteCollectionCanGetIterator()'uses' => 'FooController@index','as' => 'foo_index',]));-$this->assertInstanceOf(ArrayIterator::class, $this->routeCollection->getIterator());++$this->assertInstanceOf(ArrayIterator::class, $this->collection()->getIterator());}public function testRouteCollectionCanGetIteratorWhenEmpty(){-$this->assertCount(0, $this->routeCollection);-$this->assertInstanceOf(ArrayIterator::class, $this->routeCollection->getIterator());+$routes = $this->collection();++$this->assertCount(0, $routes);+$this->assertInstanceOf(ArrayIterator::class, $routes->getIterator());}-public function testRouteCollectionCanGetIteratorWhenRouteAreAdded()+public function testRouteCollectionCanGetIteratorWhenRoutesAreAdded(){$this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', ['uses' => 'FooController@index','as' => 'foo_index',]));-$this->assertCount(1, $this->routeCollection);++$routes = $this->collection();++$this->assertCount(1, $routes);$this->routeCollection->add($routeShow = $this->newRoute('GET', 'bar/show', ['uses' => 'BarController@show','as' => 'bar_show',]));-$this->assertCount(2, $this->routeCollection);-$this->assertInstanceOf(ArrayIterator::class, $this->routeCollection->getIterator());+$routes = $this->collection();++$this->assertCount(2, $routes);++$this->assertInstanceOf(ArrayIterator::class, $routes->getIterator());}public function testRouteCollectionCanHandleSameRoute(){-$routeIndex = $this->newRoute('GET', 'foo/index', [+$this->routeCollection->add($routeIndex = $this->newRoute('GET', 'foo/index', ['uses' => 'FooController@index','as' => 'foo_index',-]);+]));-$this->routeCollection->add($routeIndex);-$this->assertCount(1, $this->routeCollection);+$routes = $this->collection();++$this->assertCount(1, $routes);// Add exactly the same route$this->routeCollection->add($routeIndex);-$this->assertCount(1, $this->routeCollection);++$routes = $this->collection();++$this->assertCount(1, $routes);// Add a non-existing route$this->routeCollection->add($this->newRoute('GET', 'bar/show', ['uses' => 'BarController@show','as' => 'bar_show',]));-$this->assertCount(2, $this->routeCollection);++$routes = $this->collection();++$this->assertCount(2, $routes);}public function testRouteCollectionCanGetAllRoutes()@@ -145,12 +175,10 @@ public function testRouteCollectionCanGetAllRoutes()'uses' => 'FooController@index','as' => 'foo_index',]));-$this->routeCollection->add($routeShow = $this->newRoute('GET', 'foo/show', ['uses' => 'FooController@show','as' => 'foo_show',]));-$this->routeCollection->add($routeNew = $this->newRoute('POST', 'bar', ['uses' => 'BarController@create','as' => 'bar_create',@@ -161,7 +189,7 @@ public function testRouteCollectionCanGetAllRoutes()$routeShow,$routeNew,];-$this->assertEquals($allRoutes, $this->routeCollection->getRoutes());+$this->assertEquals($allRoutes, $this->collection()->getRoutes());}public function testRouteCollectionCanGetRoutesByName()@@ -185,7 +213,7 @@ public function testRouteCollectionCanGetRoutesByName()$this->routeCollection->add($routesByName['foo_show']);$this->routeCollection->add($routesByName['bar_create']);-$this->assertEquals($routesByName, $this->routeCollection->getRoutesByName());+$this->assertEquals($routesByName, $this->collection()->getRoutesByName());}public function testRouteCollectionCanGetRoutesByMethod()@@ -221,7 +249,7 @@ public function testRouteCollectionCanGetRoutesByMethod()'POST' => ['bar' => $routes['bar_create'],],-], $this->routeCollection->getRoutesByMethod());+], $this->collection()->getRoutesByMethod());}public function testRouteCollectionCleansUpOverwrittenRoutes()@@ -239,9 +267,14 @@ public function testRouteCollectionCleansUpOverwrittenRoutes()$this->assertEquals($routeB, $this->routeCollection->getByName('overwrittenRouteA'));$this->assertEquals($routeB, $this->routeCollection->getByAction('OverwrittenView@view'));+$routes = $this->collection();++// The lookups of $routeA should not be there anymore, because they are no longer valid.+$this->assertNull($routes->getByName('routeA'));+$this->assertNull($routes->getByAction('View@view'));// The lookups of $routeB are still there.-$this->assertEquals($routeB, $this->routeCollection->getByName('overwrittenRouteA'));-$this->assertEquals($routeB, $this->routeCollection->getByAction('OverwrittenView@view'));+$this->assertEquals($routeB, $routes->getByName('overwrittenRouteA'));+$this->assertEquals($routeB, $routes->getByAction('OverwrittenView@view'));}public function testMatchingThrowsNotFoundExceptionWhenRouteIsNotFound()@@ -250,23 +283,64 @@ public function testMatchingThrowsNotFoundExceptionWhenRouteIsNotFound()$this->expectException(NotFoundHttpException::class);-$this->routeCollection->match(Request::create('/foo'));+$this->collection()->match(Request::create('/foo'));}public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed(){-$this->routeCollection->add($this->newRoute('POST', '/foo', ['uses' => 'FooController@index']));+$this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index']));$this->expectException(MethodNotAllowedHttpException::class);-$this->routeCollection->match(Request::create('/foo'));+$this->collection()->match(Request::create('/foo', 'POST'));+}++public function testMatchingRouteWithSameDynamicallyAddedRouteAlwaysMatchesCachedOneFirst()+{+$this->routeCollection->add(+$route = $this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'foo'])+);++$routes = $this->collection();++$routes->add($this->newRoute('GET', '/', ['uses' => 'FooController@index', 'as' => 'bar']));++$this->assertEquals('foo', $routes->match(Request::create('/', 'GET'))->getName());+}++public function testMatchingFindsRouteWithDifferentMethodDynamically()+{+$this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index']));++$routes = $this->collection();++$routes->add($route = $this->newRoute('POST', '/foo', ['uses' => 'FooController@index']));++$this->assertSame($route, $routes->match(Request::create('/foo', 'POST')));+}++public function testMatchingWildcardFromCompiledRoutesAlwaysTakesPrecedent()+{+$this->routeCollection->add(+$route = $this->newRoute('GET', '{wildcard}', ['uses' => 'FooController@index', 'as' => 'foo'])+->where('wildcard', '.*')+);++$routes = $this->collection();++$routes->add(+$this->newRoute('GET', '{wildcard}', ['uses' => 'FooController@index', 'as' => 'bar'])+->where('wildcard', '.*')+);++$this->assertSame('foo', $routes->match(Request::create('/foo', 'GET'))->getName());}public function testSlashPrefixIsProperly(){$this->routeCollection->add($this->newRoute('GET', 'foo/bar', ['uses' => 'FooController@index', 'prefix' => '/']));-$route = $this->routeCollection->getByAction('FooController@index');+$route = $this->collection()->getByAction('FooController@index');$this->assertEquals('foo/bar', $route->uri());}@@ -279,7 +353,7 @@ public function testRouteBindingsAreProperlySaved()'as' => 'foo',]));-$route = $this->routeCollection->getByName('foo');+$route = $this->collection()->getByName('foo');$this->assertEquals('profile/{user}/posts/{post}/show', $route->uri());$this->assertSame(['user' => 'username', 'post' => 'slug'], $route->bindingFields());src/Illuminate/Routing/CompiledRouteCollection.php | 3 +--.../Routing/CompiledRouteCollectionTest.php | 13 +++++++++++++2 files changed, 14 insertions(+), 2 deletions(-)
tests/Database/DatabaseEloquentModelTest.php+10 −0
@@ -91,6 +91,16 @@ public function testDirtyAttributes()$this->assertTrue($model->isDirty(['foo', 'bar']));}+public function testIntAndNullComparisonWhenDirty()+{+$model = new EloquentModelCastingStub();+$model->intAttribute = null;+$model->syncOriginal();+$this->assertFalse($model->isDirty('intAttribute'));+$model->forceFill(['intAttribute' => 0]);+$this->assertTrue($model->isDirty('intAttribute'));+}+public function testDirtyOnCastOrDateAttributes(){$model = new EloquentModelCastingStub;src/Illuminate/Routing/UrlGenerator.php | 4 ++--tests/Routing/RoutingRouteTest.php | 26 +++++++++++++++++++++++++2 files changed, 28 insertions(+), 2 deletions(-)
tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php+14 −3
@@ -269,7 +269,6 @@ public function testUpdateModelAfterSoftDeleting()/** @var SoftDeletesTestUser $userModel */$userModel = SoftDeletesTestUser::find(2);$userModel->delete();-$userModel->syncOriginal();$this->assertEquals($now->toDateTimeString(), $userModel->getOriginal('deleted_at'));$this->assertNull(SoftDeletesTestUser::find(2));$this->assertEquals($userModel, SoftDeletesTestUser::withTrashed()->find(2));@@ -285,7 +284,6 @@ public function testRestoreAfterSoftDelete()/** @var SoftDeletesTestUser $userModel */$userModel = SoftDeletesTestUser::find(2);$userModel->delete();-$userModel->syncOriginal();$userModel->restore();$this->assertEquals($userModel->id, SoftDeletesTestUser::find(2)->id);@@ -304,12 +302,25 @@ public function testSoftDeleteAfterRestoring()$this->assertEquals($userModel->deleted_at, SoftDeletesTestUser::find(1)->deleted_at);$this->assertEquals($userModel->getOriginal('deleted_at'), SoftDeletesTestUser::find(1)->deleted_at);$userModel->delete();-$userModel->syncOriginal();$this->assertNull(SoftDeletesTestUser::find(1));$this->assertEquals($userModel->deleted_at, SoftDeletesTestUser::withTrashed()->find(1)->deleted_at);$this->assertEquals($userModel->getOriginal('deleted_at'), SoftDeletesTestUser::withTrashed()->find(1)->deleted_at);}+public function testModifyingBeforeSoftDeletingAndRestoring()+{+$this->createUsers();++/** @var SoftDeletesTestUser $userModel */+$userModel = SoftDeletesTestUser::find(2);+$userModel->email = 'foo@bar.com';+$userModel->delete();+$userModel->restore();++$this->assertEquals($userModel->id, SoftDeletesTestUser::find(2)->id);+$this->assertSame('foo@bar.com', SoftDeletesTestUser::find(2)->email);+}+public function testUpdateOrCreate(){$this->createUsers();
src/Illuminate/Routing/CompiledRouteCollection.php+7 −6
@@ -221,14 +221,15 @@ public function mapAttributesToRoutes()*/protected function newRoute(array $attributes){-if (! empty($attributes['action']['prefix'] ?? '')) {-$prefixSegments = explode('/', trim($attributes['action']['prefix'], '/'));-+if (empty($attributes['action']['prefix'] ?? '')) {+$baseUri = $attributes['uri'];+} else {$baseUri = trim(implode(-'/', array_slice(explode('/', trim($attributes['uri'], '/')), count($prefixSegments))+'/', array_slice(+explode('/', trim($attributes['uri'], '/')),+count(explode('/', trim($attributes['action']['prefix'], '/')))+)), '/');-} else {-$baseUri = $attributes['uri'];}return (new Route($attributes['methods'], $baseUri == '' ? '/' : $baseUri, $attributes['action'])).../Foundation/Testing/Concerns/InteractsWithConsole.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Routing/CompiledRouteCollection.php+4 −1
@@ -5,6 +5,7 @@use Illuminate\Container\Container;use Illuminate\Http\Request;use Illuminate\Support\Collection;+use Illuminate\Support\Str;use Symfony\Component\Routing\Matcher\CompiledUrlMatcher;use Symfony\Component\Routing\RequestContext;@@ -221,7 +222,9 @@ public function mapAttributesToRoutes()*/protected function newRoute(array $attributes){-return (new Route($attributes['methods'], $attributes['uri'], $attributes['action']))+$baseUri = ltrim(Str::replaceFirst(ltrim($attributes['action']['prefix'] ?? '', '/'), '', $attributes['uri']), '/');++return (new Route($attributes['methods'], $baseUri, $attributes['action']))->setFallback($attributes['fallback'])->setDefaults($attributes['defaults'])->setWheres($attributes['wheres'])src/Illuminate/Routing/CompiledRouteCollection.php | 6 +++++-1 file changed, 5 insertions(+), 1 deletion(-)
tests/Routing/RouteCollectionTest.php+15 −0
@@ -5,6 +5,7 @@use ArrayIterator;use Illuminate\Routing\Route;use Illuminate\Routing\RouteCollection;+use LogicException;use PHPUnit\Framework\TestCase;class RouteCollectionTest extends TestCase@@ -247,4 +248,18 @@ public function testRouteCollectionCleansUpOverwrittenRoutes()$this->assertEquals($routeB, $this->routeCollection->getByName('overwrittenRouteA'));$this->assertEquals($routeB, $this->routeCollection->getByAction('OverwrittenView@view'));}++public function testCannotCacheDuplicateRouteNames()+{+$this->routeCollection->add(+new Route('GET', 'users', ['uses' => 'UsersController@index', 'as' => 'users'])+);+$this->routeCollection->add(+new Route('GET', 'users/{user}', ['uses' => 'UsersController@show', 'as' => 'users'])+);++$this->expectException(LogicException::class);++$this->routeCollection->compile();+}}
src/Illuminate/View/Component.php+1 −1
@@ -218,7 +218,7 @@ public function withAttributes(array $attributes){$this->attributes = $this->attributes ?: new ComponentAttributeBag;-$this->attributes = $this->attributes->merge($attributes);+$this->attributes->setAttributes($attributes);return $this;}
src/Illuminate/Routing/CompiledRouteCollection.php+9 −5
@@ -222,11 +222,15 @@ public function mapAttributesToRoutes()*/protected function newRoute(array $attributes){-$baseUri = ltrim(Str::replaceFirst(-ltrim($attributes['action']['prefix'] ?? '', '/'),-'',-$attributes['uri']-), '/');+if (! empty($attributes['action']['prefix'] ?? '')) {+$prefixSegments = explode('/', trim($attributes['action']['prefix'], '/'));++$baseUri = trim(implode(+'/', array_slice(explode('/', trim($attributes['uri'], '/')), count($prefixSegments))+), '/');+} else {+$baseUri = $attributes['uri'];+}return (new Route($attributes['methods'], $baseUri == '' ? '/' : $baseUri, $attributes['action']))->setFallback($attributes['fallback'])src/Illuminate/Routing/CompiledRouteCollection.php | 1 -1 file changed, 1 deletion(-)
tests/View/ViewComponentTest.php+0 −11
@@ -70,17 +70,6 @@ public function testMethodsOverridePropertyValues()$this->assertArrayHasKey('world', $variables);$this->assertEquals('world property', $variables['world']);}--public function testAttributesAreMergedNotOverwritten()-{-$component = new TestDefaultAttributesComponent;--$this->assertEquals('text-red-500', $component->attributes->get('class'));--$component->withAttributes(['class' => 'bg-blue-100']);--$this->assertEquals('bg-blue-100 text-red-500', $component->attributes->get('class'));-}}class TestViewComponent extends Componentsrc/Illuminate/Foundation/Application.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Routing/CompiledRouteCollection.php+24 −0
@@ -78,6 +78,30 @@ public function add(Route $route)return $route;}+/**+* Refresh the name look-up table.+*+* This is done in case any names are fluently defined or if routes are overwritten.+*+* @return void+*/+public function refreshNameLookups()+{+//+}++/**+* Refresh the action look-up table.+*+* This is done in case any actions are overwritten with new controllers.+*+* @return void+*/+public function refreshActionLookups()+{+//+}+/*** Find the first route matching a given request.*
src/Illuminate/Routing/RouteCollectionInterface.php+18 −0
@@ -14,6 +14,24 @@ interface RouteCollectionInterface*/public function add(Route $route);+/**+* Refresh the name look-up table.+*+* This is done in case any names are fluently defined or if routes are overwritten.+*+* @return void+*/+public function refreshNameLookups();++/**+* Refresh the action look-up table.+*+* This is done in case any actions are overwritten with new controllers.+*+* @return void+*/+public function refreshActionLookups();+/*** Find the first route matching a given request.*src/Illuminate/Foundation/Application.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Routing/CompiledRouteCollection.php+5 −1
@@ -222,7 +222,11 @@ public function mapAttributesToRoutes()*/protected function newRoute(array $attributes){-$baseUri = ltrim(Str::replaceFirst(ltrim($attributes['action']['prefix'] ?? '', '/'), '', $attributes['uri']), '/');+$baseUri = ltrim(Str::replaceFirst(+ltrim($attributes['action']['prefix'] ?? '', '/'),+'',+$attributes['uri']+), '/');return (new Route($attributes['methods'], $baseUri, $attributes['action']))->setFallback($attributes['fallback'])src/Illuminate/Routing/CompiledRouteCollection.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Routing/CompiledRouteCollection.php+1 −1
@@ -228,7 +228,7 @@ protected function newRoute(array $attributes)$attributes['uri']), '/');-return (new Route($attributes['methods'], $baseUri, $attributes['action']))+return (new Route($attributes['methods'], $baseUri == '' ? '/' : $baseUri, $attributes['action']))->setFallback($attributes['fallback'])->setDefaults($attributes['defaults'])->setWheres($attributes['wheres'])src/Illuminate/Foundation/Application.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Routing/AbstractRouteCollection.php+3 −0
@@ -8,6 +8,7 @@use Illuminate\Http\Response;use Illuminate\Support\Str;use IteratorAggregate;+use LogicException;use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;use Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper;@@ -200,6 +201,8 @@ protected function addToSymfonyRoutesCollection(SymfonyRouteCollection $symfonyR$route->name($name = $this->generateRouteName());$this->add($route);+} elseif (! is_null($symfonyRoutes->get($name))) {+throw new LogicException("Unable to prepare route [{$route->uri}] for serialization. Another route has already been assigned name [{$name}].");}$symfonyRoutes->add($name, $route->toSymfonyRoute());tests/Routing/RoutingRouteTest.php | 1 +1 file changed, 1 insertion(+)