diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 000000000000..1625bda1002c --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,9 @@ +name: update changelog + +on: + release: + types: [released] + +jobs: + update: + uses: laravel/.github/.github/workflows/update-changelog.yml@main diff --git a/config/mail.php b/config/mail.php index a3be42c1e01a..ad513854ba17 100644 --- a/config/mail.php +++ b/config/mail.php @@ -55,7 +55,7 @@ 'postmark' => [ 'transport' => 'postmark', - // 'message_stream_id' => null, + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), // 'client' => [ // 'timeout' => 5, // ], @@ -87,6 +87,14 @@ ], ], + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + ], + ], /* diff --git a/config/services.php b/config/services.php index 6bb68f6aece2..27a36175f823 100644 --- a/config/services.php +++ b/config/services.php @@ -24,6 +24,10 @@ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'resend' => [ + 'key' => env('RESEND_KEY'), + ], + 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), diff --git a/config/session.php b/config/session.php index d1dc325d4a16..f0b6541e5890 100644 --- a/config/session.php +++ b/config/session.php @@ -191,7 +191,7 @@ | | This option determines how your cookies behave when cross-site requests | take place, and can be used to mitigate CSRF attacks. By default, we - | will set this value to "lax" since this is a secure default value. + | will set this value to "lax" to permit secure cross-site requests. | | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value | diff --git a/src/Illuminate/Cache/Lock.php b/src/Illuminate/Cache/Lock.php index 4868fdf82a50..435df78f384a 100644 --- a/src/Illuminate/Cache/Lock.php +++ b/src/Illuminate/Cache/Lock.php @@ -150,7 +150,18 @@ public function owner() */ public function isOwnedByCurrentProcess() { - return $this->getCurrentOwner() === $this->owner; + return $this->isOwnedBy($this->owner); + } + + /** + * Determine whether this lock is owned by the given identifier. + * + * @param string|null $owner + * @return bool + */ + public function isOwnedBy($owner) + { + return $this->getCurrentOwner() === $owner; } /** diff --git a/src/Illuminate/Collections/Collection.php b/src/Illuminate/Collections/Collection.php index b830635dce5a..60d1daf3e137 100644 --- a/src/Illuminate/Collections/Collection.php +++ b/src/Illuminate/Collections/Collection.php @@ -75,25 +75,6 @@ public function lazy() return new LazyCollection($this->items); } - /** - * Get the average value of a given key. - * - * @param (callable(TValue): float|int)|string|null $callback - * @return float|int|null - */ - public function avg($callback = null) - { - $callback = $this->valueRetriever($callback); - - $items = $this - ->map(fn ($value) => $callback($value)) - ->filter(fn ($value) => ! is_null($value)); - - if ($count = $items->count()) { - return $items->sum() / $count; - } - } - /** * Get the median of a given key. * diff --git a/src/Illuminate/Collections/LazyCollection.php b/src/Illuminate/Collections/LazyCollection.php index 95fd218b36f0..e6269071f8ca 100644 --- a/src/Illuminate/Collections/LazyCollection.php +++ b/src/Illuminate/Collections/LazyCollection.php @@ -154,17 +154,6 @@ public function remember() }); } - /** - * Get the average value of a given key. - * - * @param (callable(TValue): float|int)|string|null $callback - * @return float|int|null - */ - public function avg($callback = null) - { - return $this->collect()->avg($callback); - } - /** * Get the median of a given key. * diff --git a/src/Illuminate/Collections/Traits/EnumeratesValues.php b/src/Illuminate/Collections/Traits/EnumeratesValues.php index 1b0f63fdf048..86e68f1e9ac4 100644 --- a/src/Illuminate/Collections/Traits/EnumeratesValues.php +++ b/src/Illuminate/Collections/Traits/EnumeratesValues.php @@ -175,6 +175,28 @@ public static function times($number, ?callable $callback = null) ->map($callback); } + /** + * Get the average value of a given key. + * + * @param (callable(TValue): float|int)|string|null $callback + * @return float|int|null + */ + public function avg($callback = null) + { + $callback = $this->valueRetriever($callback); + + $reduced = $this->reduce(static function (&$reduce, $value) use ($callback) { + if (! is_null($resolved = $callback($value))) { + $reduce[0] += $resolved; + $reduce[1]++; + } + + return $reduce; + }, [0, 0]); + + return $reduced[1] ? $reduced[0] / $reduced[1] : null; + } + /** * Alias for the "avg" method. * diff --git a/src/Illuminate/Console/Command.php b/src/Illuminate/Console/Command.php index 4fc256a796ad..2713562fb162 100755 --- a/src/Illuminate/Console/Command.php +++ b/src/Illuminate/Console/Command.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class Command extends SymfonyCommand { @@ -210,6 +211,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { return (int) $this->laravel->call([$this, $method]); + } catch (ManuallyFailedException $e) { + $this->components->error($e->getMessage()); + + return static::FAILURE; } finally { if ($this instanceof Isolatable && $this->option('isolated') !== false) { $this->commandIsolationMutex()->forget($this); @@ -254,6 +259,25 @@ protected function resolveCommand($command) return $command; } + /** + * Fail the command manually. + * + * @param \Throwable|string|null $exception + * @return void + */ + public function fail(Throwable|string|null $exception = null) + { + if (is_null($exception)) { + $exception = 'Command failed manually.'; + } + + if (is_string($exception)) { + $exception = new ManuallyFailedException($exception); + } + + throw $exception; + } + /** * {@inheritdoc} * diff --git a/src/Illuminate/Console/ManuallyFailedException.php b/src/Illuminate/Console/ManuallyFailedException.php new file mode 100644 index 000000000000..50431702a2e1 --- /dev/null +++ b/src/Illuminate/Console/ManuallyFailedException.php @@ -0,0 +1,10 @@ +getColumns($table); foreach ($columns as $value) { - if (strtolower($value['name']) === $column) { + if (strtolower($value['name']) === strtolower($column)) { return $fullDefinition ? $value['type'] : $value['type_name']; } } diff --git a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php index 6a77645787fb..218e5210fc47 100644 --- a/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php +++ b/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php @@ -182,8 +182,8 @@ public function withRouting(?Closure $using = null, * @param callable|null $then * @return \Closure */ - protected function buildRoutingCallback(array|string|null $web = null, - array|string|null $api = null, + protected function buildRoutingCallback(array|string|null $web, + array|string|null $api, ?string $pages, ?string $health, string $apiPrefix, diff --git a/src/Illuminate/Foundation/Console/ClosureCommand.php b/src/Illuminate/Foundation/Console/ClosureCommand.php index ae51801fe0a0..2c2eaf4d2744 100644 --- a/src/Illuminate/Foundation/Console/ClosureCommand.php +++ b/src/Illuminate/Foundation/Console/ClosureCommand.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Console\Command; +use Illuminate\Console\ManuallyFailedException; use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Traits\ForwardsCalls; use ReflectionFunction; @@ -58,9 +59,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - return (int) $this->laravel->call( - $this->callback->bindTo($this, $this), $parameters - ); + try { + return (int) $this->laravel->call( + $this->callback->bindTo($this, $this), $parameters + ); + } catch (ManuallyFailedException $e) { + $this->components->error($e->getMessage()); + + return static::FAILURE; + } } /** diff --git a/src/Illuminate/Foundation/Console/MailMakeCommand.php b/src/Illuminate/Foundation/Console/MailMakeCommand.php index af02001c7a2c..31ef3c394be5 100644 --- a/src/Illuminate/Foundation/Console/MailMakeCommand.php +++ b/src/Illuminate/Foundation/Console/MailMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Console\GeneratorCommand; +use Illuminate\Foundation\Inspiring; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -48,6 +49,10 @@ public function handle() if ($this->option('markdown') !== false) { $this->writeMarkdownTemplate(); } + + if ($this->option('view') !== false) { + $this->writeView(); + } } /** @@ -61,13 +66,33 @@ protected function writeMarkdownTemplate() str_replace('.', '/', $this->getView()).'.blade.php' ); - if (! $this->files->isDirectory(dirname($path))) { - $this->files->makeDirectory(dirname($path), 0755, true); - } + $this->files->ensureDirectoryExists(dirname($path)); $this->files->put($path, file_get_contents(__DIR__.'/stubs/markdown.stub')); } + /** + * Write the Blade template for the mailable. + * + * @return void + */ + protected function writeView() + { + $path = $this->viewPath( + str_replace('.', '/', $this->getView()).'.blade.php' + ); + + $this->files->ensureDirectoryExists(dirname($path)); + + $stub = str_replace( + '{{ quote }}', + Inspiring::quotes()->random(), + file_get_contents(__DIR__.'/stubs/view.stub') + ); + + $this->files->put($path, $stub); + } + /** * Build the class with the given name. * @@ -82,7 +107,7 @@ protected function buildClass($name) parent::buildClass($name) ); - if ($this->option('markdown') !== false) { + if ($this->option('markdown') !== false || $this->option('view') !== false) { $class = str_replace(['DummyView', '{{ view }}'], $this->getView(), $class); } @@ -96,7 +121,7 @@ protected function buildClass($name) */ protected function getView() { - $view = $this->option('markdown'); + $view = $this->option('markdown') ?: $this->option('view'); if (! $view) { $name = str_replace('\\', '/', $this->argument('name')); @@ -116,10 +141,15 @@ protected function getView() */ protected function getStub() { - return $this->resolveStubPath( - $this->option('markdown') !== false - ? '/stubs/markdown-mail.stub' - : '/stubs/mail.stub'); + if ($this->option('markdown') !== false) { + return $this->resolveStubPath('/stubs/markdown-mail.stub'); + } + + if ($this->option('view') !== false) { + return $this->resolveStubPath('/stubs/view-mail.stub'); + } + + return $this->resolveStubPath('/stubs/mail.stub'); } /** @@ -156,6 +186,7 @@ protected function getOptions() return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the mailable already exists'], ['markdown', 'm', InputOption::VALUE_OPTIONAL, 'Create a new Markdown template for the mailable', false], + ['view', null, InputOption::VALUE_OPTIONAL, 'Create a new Blade template for the mailable', false], ]; } } diff --git a/src/Illuminate/Foundation/Console/stubs/view-mail.stub b/src/Illuminate/Foundation/Console/stubs/view-mail.stub new file mode 100644 index 000000000000..8889396d5869 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/view-mail.stub @@ -0,0 +1,53 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/src/Illuminate/Foundation/Mix.php b/src/Illuminate/Foundation/Mix.php index 6c861c72d081..f06deb956e83 100644 --- a/src/Illuminate/Foundation/Mix.php +++ b/src/Illuminate/Foundation/Mix.php @@ -15,7 +15,7 @@ class Mix * @param string $manifestDirectory * @return \Illuminate\Support\HtmlString|string * - * @throws \Exception + * @throws \Illuminate\Foundation\MixManifestNotFoundException */ public function __invoke($path, $manifestDirectory = '') { @@ -49,7 +49,7 @@ public function __invoke($path, $manifestDirectory = '') if (! isset($manifests[$manifestPath])) { if (! is_file($manifestPath)) { - throw new Exception("Mix manifest not found at: {$manifestPath}"); + throw new MixManifestNotFoundException("Mix manifest not found at: {$manifestPath}"); } $manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true); diff --git a/src/Illuminate/Foundation/MixManifestNotFoundException.php b/src/Illuminate/Foundation/MixManifestNotFoundException.php new file mode 100644 index 000000000000..9931be1216ef --- /dev/null +++ b/src/Illuminate/Foundation/MixManifestNotFoundException.php @@ -0,0 +1,10 @@ +register('file', function () { - return new FileEngine($this->app['files']); + return new FileEngine(app()->make('files')); }); } @@ -148,7 +148,7 @@ public function registerFileEngine($resolver) public function registerPhpEngine($resolver) { $resolver->register('php', function () { - return new PhpEngine($this->app['files']); + return new PhpEngine(app()->make('files')); }); } @@ -161,9 +161,14 @@ public function registerPhpEngine($resolver) public function registerBladeEngine($resolver) { $resolver->register('blade', function () { - $compiler = new CompilerEngine($this->app['blade.compiler'], $this->app['files']); + $app = app(); - $this->app->terminating(static function () use ($compiler) { + $compiler = new CompilerEngine( + $app->make('blade.compiler'), + $app->make('files'), + ); + + $app->terminating(static function () use ($compiler) { $compiler->forgetCompiledOrNotExpired(); }); diff --git a/tests/Integration/Cache/FileCacheLockTest.php b/tests/Integration/Cache/FileCacheLockTest.php index 594471806393..c9eff5ced0e9 100644 --- a/tests/Integration/Cache/FileCacheLockTest.php +++ b/tests/Integration/Cache/FileCacheLockTest.php @@ -104,9 +104,12 @@ public function testOtherOwnerDoesNotOwnLockAfterRestore() Cache::lock('foo')->forceRelease(); $firstLock = Cache::lock('foo', 10); + $this->assertTrue($firstLock->isOwnedBy(null)); $this->assertTrue($firstLock->get()); + $this->assertTrue($firstLock->isOwnedBy($firstLock->owner())); $secondLock = Cache::store('file')->restoreLock('foo', 'other_owner'); + $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); $this->assertFalse($secondLock->isOwnedByCurrentProcess()); } } diff --git a/tests/Integration/Cache/MemcachedCacheLockTestCase.php b/tests/Integration/Cache/MemcachedCacheLockTestCase.php index e4fc82dca804..d819fb9fd73d 100644 --- a/tests/Integration/Cache/MemcachedCacheLockTestCase.php +++ b/tests/Integration/Cache/MemcachedCacheLockTestCase.php @@ -97,9 +97,12 @@ public function testOtherOwnerDoesNotOwnLockAfterRestore() Cache::store('memcached')->lock('foo')->forceRelease(); $firstLock = Cache::store('memcached')->lock('foo', 10); + $this->assertTrue($firstLock->isOwnedBy(null)); $this->assertTrue($firstLock->get()); + $this->assertTrue($firstLock->isOwnedBy($firstLock->owner())); $secondLock = Cache::store('memcached')->restoreLock('foo', 'other_owner'); + $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); $this->assertFalse($secondLock->isOwnedByCurrentProcess()); } } diff --git a/tests/Integration/Cache/RedisCacheLockTest.php b/tests/Integration/Cache/RedisCacheLockTest.php index b5de1eb58891..064e84344a40 100644 --- a/tests/Integration/Cache/RedisCacheLockTest.php +++ b/tests/Integration/Cache/RedisCacheLockTest.php @@ -126,9 +126,12 @@ public function testOtherOwnerDoesNotOwnLockAfterRestore() Cache::store('redis')->lock('foo')->forceRelease(); $firstLock = Cache::store('redis')->lock('foo', 10); + $this->assertTrue($firstLock->isOwnedBy(null)); $this->assertTrue($firstLock->get()); + $this->assertTrue($firstLock->isOwnedBy($firstLock->owner())); $secondLock = Cache::store('redis')->restoreLock('foo', 'other_owner'); + $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); $this->assertFalse($secondLock->isOwnedByCurrentProcess()); } } diff --git a/tests/Integration/Console/CommandManualFailTest.php b/tests/Integration/Console/CommandManualFailTest.php new file mode 100644 index 000000000000..4062139e0f3d --- /dev/null +++ b/tests/Integration/Console/CommandManualFailTest.php @@ -0,0 +1,59 @@ +resolveCommands([ + FailingCommandStub::class, + ]); + }); + + parent::setUp(); + } + + public function testFailArtisanCommandManually() + { + $this->artisan('app:fail')->assertFailed(); + } + + public function testCreatesAnExceptionFromString() + { + $this->expectException(ManuallyFailedException::class); + $command = new Command; + $command->fail('Whoops!'); + } + + public function testCreatesAnExceptionFromNull() + { + $this->expectException(ManuallyFailedException::class); + $command = new Command; + $command->fail(); + } +} + +class FailingCommandStub extends Command +{ + protected $signature = 'app:fail'; + + public function handle() + { + $this->trigger_failure(); + + // This should never be reached. + return static::SUCCESS; + } + + protected function trigger_failure() + { + $this->fail('Whoops!'); + } +} diff --git a/tests/Integration/Database/DatabaseLockTest.php b/tests/Integration/Database/DatabaseLockTest.php index 08f848e22543..2424539d3391 100644 --- a/tests/Integration/Database/DatabaseLockTest.php +++ b/tests/Integration/Database/DatabaseLockTest.php @@ -56,4 +56,16 @@ public function testExpiredLockCanBeRetrieved() $otherLock->release(); } + + public function testOtherOwnerDoesNotOwnLockAfterRestore() + { + $firstLock = Cache::store('database')->lock('foo'); + $this->assertTrue($firstLock->isOwnedBy(null)); + $this->assertTrue($firstLock->get()); + $this->assertTrue($firstLock->isOwnedBy($firstLock->owner())); + + $secondLock = Cache::store('database')->restoreLock('foo', 'other_owner'); + $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); + $this->assertFalse($secondLock->isOwnedByCurrentProcess()); + } } diff --git a/tests/Integration/Generators/MailMakeCommandTest.php b/tests/Integration/Generators/MailMakeCommandTest.php index 8da9c0785ccc..06b418fec164 100644 --- a/tests/Integration/Generators/MailMakeCommandTest.php +++ b/tests/Integration/Generators/MailMakeCommandTest.php @@ -46,6 +46,22 @@ public function testItCanGenerateMailFileWithMarkdownOption() ], 'resources/views/foo-mail.blade.php'); } + public function testItCanGenerateMailFileWithViewOption() + { + $this->artisan('make:mail', ['name' => 'FooMail', '--view' => 'foo-mail']) + ->assertExitCode(0); + + $this->assertFileContains([ + 'namespace App\Mail;', + 'use Illuminate\Mail\Mailable;', + 'class FooMail extends Mailable', + 'return new Content(', + "view: 'foo-mail',", + ], 'app/Mail/FooMail.php'); + + $this->assertFilenameExists('resources/views/foo-mail.blade.php'); + } + public function testItCanGenerateMailFileWithTest() { $this->artisan('make:mail', ['name' => 'FooMail', '--test' => true]) diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 0c5e5e6d9c93..01de6e32904d 100755 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -4076,6 +4076,9 @@ public function testGettingAvgItemsFromCollection($collection) (object) ['foo' => 6], ]); $this->assertEquals(3, $c->avg('foo')); + + $c = new $collection([0]); + $this->assertEquals(0, $c->avg()); } #[DataProvider('collectionClassProvider')] diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 4b9ce667fcb2..fb073810c851 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -112,6 +112,18 @@ public function testStringApa() $this->assertSame('To Kill a Mockingbird', Str::apa('TO KILL A MOCKINGBIRD')); $this->assertSame('To Kill a Mockingbird', Str::apa('To Kill A Mockingbird')); + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('Être écrivain commence par être un lecteur.')); + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('Être Écrivain Commence par Être un Lecteur.')); + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('ÊTRE ÉCRIVAIN COMMENCE PAR ÊTRE UN LECTEUR.')); + + $this->assertSame("C'est-à-Dire.", Str::apa("c'est-à-dire.")); + $this->assertSame("C'est-à-Dire.", Str::apa("C'est-à-Dire.")); + $this->assertSame("C'est-à-Dire.", Str::apa("C'EsT-À-DIRE.")); + + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('устное слово – не воробей. как только он вылетит, его не поймаешь.')); + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.')); + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('УСТНОЕ СЛОВО – НЕ ВОРОБЕЙ. КАК ТОЛЬКО ОН ВЫЛЕТИТ, ЕГО НЕ ПОЙМАЕШЬ.')); + $this->assertSame('', Str::apa('')); $this->assertSame(' ', Str::apa(' ')); } @@ -125,10 +137,12 @@ public function testStringWithoutWordsDoesntProduceError(): void $this->assertSame("\t\t\t", Str::words("\t\t\t")); } - public function testStringAscii() + public function testStringAscii(): void { $this->assertSame('@', Str::ascii('@')); $this->assertSame('u', Str::ascii('ü')); + $this->assertSame('', Str::ascii('')); + $this->assertSame('a!2e', Str::ascii('a!2ë')); } public function testStringAsciiWithSpecificLocale() @@ -257,7 +271,7 @@ public function testStrExcerpt() $this->assertNull(Str::excerpt('', '/')); } - public function testStrBefore() + public function testStrBefore(): void { $this->assertSame('han', Str::before('hannah', 'nah')); $this->assertSame('ha', Str::before('hannah', 'n')); @@ -267,6 +281,12 @@ public function testStrBefore() $this->assertSame('han', Str::before('han0nah', '0')); $this->assertSame('han', Str::before('han0nah', 0)); $this->assertSame('han', Str::before('han2nah', 2)); + $this->assertSame('', Str::before('', '')); + $this->assertSame('', Str::before('', 'a')); + $this->assertSame('', Str::before('a', 'a')); + $this->assertSame('foo', Str::before('foo@bar.com', '@')); + $this->assertSame('foo', Str::before('foo@@bar.com', '@')); + $this->assertSame('', Str::before('@foo@bar.com', '@')); } public function testStrBeforeLast(): void @@ -286,7 +306,7 @@ public function testStrBeforeLast(): void $this->assertSame('yvette', Str::beforeLast("yvette\tyv0et0te", "\t")); } - public function testStrBetween() + public function testStrBetween(): void { $this->assertSame('abc', Str::between('abc', '', 'c')); $this->assertSame('abc', Str::between('abc', 'a', '')); @@ -299,6 +319,9 @@ public function testStrBetween() $this->assertSame('a]ab[b', Str::between('[a]ab[b]', '[', ']')); $this->assertSame('foo', Str::between('foofoobar', 'foo', 'bar')); $this->assertSame('bar', Str::between('foobarbar', 'foo', 'bar')); + $this->assertSame('234', Str::between('12345', 1, 5)); + $this->assertSame('45', Str::between('123456789', '123', '6789')); + $this->assertSame('nothing', Str::between('nothing', 'foo', 'bar')); } public function testStrBetweenFirst()