├── LICENSE.md
├── README.md
├── bin
└── laravel
├── composer.json
└── src
├── Concerns
├── ConfiguresPrompts.php
└── InteractsWithHerdOrValet.php
└── NewCommand.php
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Taylor Otwell
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Installer
2 |
3 |
4 |
5 |
6 |
7 |
8 | ## Official Documentation
9 |
10 | Documentation for installing Laravel can be found on the [Laravel website](https://laravel.com/docs#creating-a-laravel-project).
11 |
12 | ## Contributing
13 |
14 | Thank you for considering contributing to the Installer! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
15 |
16 | ## Code of Conduct
17 |
18 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
19 |
20 | ## Security Vulnerabilities
21 |
22 | Please review [our security policy](https://github.com/laravel/installer/security/policy) on how to report security vulnerabilities.
23 |
24 | ## License
25 |
26 | Laravel Installer is open-sourced software licensed under the [MIT license](LICENSE.md).
27 |
--------------------------------------------------------------------------------
/bin/laravel:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | add(new Laravel\Installer\Console\NewCommand);
12 |
13 | $app->run();
14 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laravel/installer",
3 | "description": "Laravel application installer.",
4 | "keywords": [
5 | "laravel"
6 | ],
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "Taylor Otwell",
11 | "email": "taylor@laravel.com"
12 | }
13 | ],
14 | "require": {
15 | "php": "^8.2",
16 | "illuminate/filesystem": "^10.20|^11.0|^12.0",
17 | "illuminate/support": "^10.20|^11.0|^12.0",
18 | "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0",
19 | "symfony/console": "^6.2|^7.0",
20 | "symfony/process": "^6.2|^7.0",
21 | "symfony/polyfill-mbstring": "^1.31"
22 | },
23 | "require-dev": {
24 | "phpstan/phpstan": "^2.1",
25 | "phpunit/phpunit": "^10.4"
26 | },
27 | "bin": [
28 | "bin/laravel"
29 | ],
30 | "autoload": {
31 | "psr-4": {
32 | "Laravel\\Installer\\Console\\": "src/"
33 | }
34 | },
35 | "autoload-dev": {
36 | "psr-4": {
37 | "Laravel\\Installer\\Console\\Tests\\": "tests/"
38 | }
39 | },
40 | "config": {
41 | "sort-packages": true
42 | },
43 | "minimum-stability": "dev",
44 | "prefer-stable": true
45 | }
46 |
--------------------------------------------------------------------------------
/src/Concerns/ConfiguresPrompts.php:
--------------------------------------------------------------------------------
1 | isInteractive() || PHP_OS_FAMILY === 'Windows');
29 |
30 | TextPrompt::fallbackUsing(fn (TextPrompt $prompt) => $this->promptUntilValid(
31 | fn () => (new SymfonyStyle($input, $output))->ask($prompt->label, $prompt->default ?: null) ?? '',
32 | $prompt->required,
33 | $prompt->validate,
34 | $output
35 | ));
36 |
37 | PasswordPrompt::fallbackUsing(fn (PasswordPrompt $prompt) => $this->promptUntilValid(
38 | fn () => (new SymfonyStyle($input, $output))->askHidden($prompt->label) ?? '',
39 | $prompt->required,
40 | $prompt->validate,
41 | $output
42 | ));
43 |
44 | ConfirmPrompt::fallbackUsing(fn (ConfirmPrompt $prompt) => $this->promptUntilValid(
45 | fn () => (new SymfonyStyle($input, $output))->confirm($prompt->label, $prompt->default),
46 | $prompt->required,
47 | $prompt->validate,
48 | $output
49 | ));
50 |
51 | SelectPrompt::fallbackUsing(fn (SelectPrompt $prompt) => $this->promptUntilValid(
52 | fn () => (new SymfonyStyle($input, $output))->choice($prompt->label, $prompt->options, $prompt->default),
53 | false,
54 | $prompt->validate,
55 | $output
56 | ));
57 |
58 | MultiSelectPrompt::fallbackUsing(function (MultiSelectPrompt $prompt) use ($input, $output) {
59 | if ($prompt->default !== []) {
60 | return $this->promptUntilValid(
61 | fn () => (new SymfonyStyle($input, $output))->choice($prompt->label, $prompt->options, implode(',', $prompt->default), true),
62 | $prompt->required,
63 | $prompt->validate,
64 | $output
65 | );
66 | }
67 |
68 | return $this->promptUntilValid(
69 | fn () => collect((new SymfonyStyle($input, $output))->choice(
70 | $prompt->label,
71 | array_is_list($prompt->options)
72 | ? ['None', ...$prompt->options]
73 | : ['none' => 'None', ...$prompt->options],
74 | 'None',
75 | true)
76 | )->reject(array_is_list($prompt->options) ? 'None' : 'none')->all(),
77 | $prompt->required,
78 | $prompt->validate,
79 | $output
80 | );
81 | });
82 |
83 | SuggestPrompt::fallbackUsing(fn (SuggestPrompt $prompt) => $this->promptUntilValid(
84 | function () use ($prompt, $input, $output) {
85 | $question = new Question($prompt->label, $prompt->default);
86 |
87 | is_callable($prompt->options)
88 | ? $question->setAutocompleterCallback($prompt->options)
89 | : $question->setAutocompleterValues($prompt->options);
90 |
91 | return (new SymfonyStyle($input, $output))->askQuestion($question);
92 | },
93 | $prompt->required,
94 | $prompt->validate,
95 | $output
96 | ));
97 | }
98 |
99 | /**
100 | * Prompt the user until the given validation callback passes.
101 | *
102 | * @param \Closure $prompt
103 | * @param bool|string $required
104 | * @param \Closure|null $validate
105 | * @param \Symfony\Component\Console\Output\OutputInterface $output
106 | * @return mixed
107 | */
108 | protected function promptUntilValid($prompt, $required, $validate, $output)
109 | {
110 | while (true) {
111 | $result = $prompt();
112 |
113 | if ($required && ($result === '' || $result === [] || $result === false)) {
114 | $output->writeln(''.(is_string($required) ? $required : 'Required.').'');
115 |
116 | continue;
117 | }
118 |
119 | if ($validate) {
120 | $error = $validate($result);
121 |
122 | if (is_string($error) && strlen($error) > 0) {
123 | $output->writeln("{$error}");
124 |
125 | continue;
126 | }
127 | }
128 |
129 | return $result;
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/Concerns/InteractsWithHerdOrValet.php:
--------------------------------------------------------------------------------
1 | runOnValetOrHerd('paths');
19 |
20 | $decodedOutput = json_decode($output);
21 |
22 | return is_array($decodedOutput) && in_array(dirname($directory), $decodedOutput);
23 | }
24 |
25 | /**
26 | * Runs the given command on the "herd" or "valet" CLI.
27 | *
28 | * @param string $command
29 | * @return string|false
30 | */
31 | protected function runOnValetOrHerd(string $command)
32 | {
33 | foreach (['herd', 'valet'] as $tool) {
34 | $process = new Process([$tool, $command, '-v']);
35 |
36 | try {
37 | $process->run();
38 |
39 | if ($process->isSuccessful()) {
40 | return trim($process->getOutput());
41 | }
42 | } catch (ProcessStartFailedException) {
43 | }
44 | }
45 |
46 | return false;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/NewCommand.php:
--------------------------------------------------------------------------------
1 | setName('new')
45 | ->setDescription('Create a new Laravel application')
46 | ->addArgument('name', InputArgument::REQUIRED)
47 | ->addOption('dev', null, InputOption::VALUE_NONE, 'Install the latest "development" release')
48 | ->addOption('git', null, InputOption::VALUE_NONE, 'Initialize a Git repository')
49 | ->addOption('branch', null, InputOption::VALUE_REQUIRED, 'The branch that should be created for a new repository', $this->defaultBranch())
50 | ->addOption('github', null, InputOption::VALUE_OPTIONAL, 'Create a new repository on GitHub', false)
51 | ->addOption('organization', null, InputOption::VALUE_REQUIRED, 'The GitHub organization to create the new repository for')
52 | ->addOption('database', null, InputOption::VALUE_REQUIRED, 'The database driver your application will use')
53 | ->addOption('react', null, InputOption::VALUE_NONE, 'Install the React Starter Kit')
54 | ->addOption('vue', null, InputOption::VALUE_NONE, 'Install the Vue Starter Kit')
55 | ->addOption('livewire', null, InputOption::VALUE_NONE, 'Install the Livewire Starter Kit')
56 | ->addOption('livewire-class-components', null, InputOption::VALUE_NONE, 'Generate stand-alone Livewire class components')
57 | ->addOption('workos', null, InputOption::VALUE_NONE, 'Use WorkOS for authentication')
58 | ->addOption('pest', null, InputOption::VALUE_NONE, 'Install the Pest testing framework')
59 | ->addOption('phpunit', null, InputOption::VALUE_NONE, 'Install the PHPUnit testing framework')
60 | ->addOption('npm', null, InputOption::VALUE_NONE, 'Install and build NPM dependencies')
61 | ->addOption('using', null, InputOption::VALUE_OPTIONAL, 'Install a custom starter kit from a community maintained package')
62 | ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces install even if the directory already exists');
63 | }
64 |
65 | /**
66 | * Interact with the user before validating the input.
67 | *
68 | * @param \Symfony\Component\Console\Input\InputInterface $input
69 | * @param \Symfony\Component\Console\Output\OutputInterface $output
70 | * @return void
71 | */
72 | protected function interact(InputInterface $input, OutputInterface $output)
73 | {
74 | parent::interact($input, $output);
75 |
76 | $this->configurePrompts($input, $output);
77 |
78 | $output->write(PHP_EOL.' _ _
79 | | | | |
80 | | | __ _ _ __ __ ___ _____| |
81 | | | / _` | __/ _` \ \ / / _ \ |
82 | | |___| (_| | | | (_| |\ V / __/ |
83 | |______\__,_|_| \__,_| \_/ \___|_|>'.PHP_EOL.PHP_EOL);
84 |
85 | $this->ensureExtensionsAreAvailable($input, $output);
86 |
87 | if (! $input->getArgument('name')) {
88 | $input->setArgument('name', text(
89 | label: 'What is the name of your project?',
90 | placeholder: 'E.g. example-app',
91 | required: 'The project name is required.',
92 | validate: function ($value) use ($input) {
93 | if (preg_match('/[^\pL\pN\-_.]/', $value) !== 0) {
94 | return 'The name may only contain letters, numbers, dashes, underscores, and periods.';
95 | }
96 |
97 | if ($input->getOption('force') !== true) {
98 | try {
99 | $this->verifyApplicationDoesntExist($this->getInstallationDirectory($value));
100 | } catch (RuntimeException $e) {
101 | return 'Application already exists.';
102 | }
103 | }
104 | },
105 | ));
106 | }
107 |
108 | if ($input->getOption('force') !== true) {
109 | $this->verifyApplicationDoesntExist(
110 | $this->getInstallationDirectory($input->getArgument('name'))
111 | );
112 | }
113 |
114 | if (! $this->usingStarterKit($input)) {
115 | match (select(
116 | label: 'Which starter kit would you like to install?',
117 | options: [
118 | 'none' => 'None',
119 | 'react' => 'React',
120 | 'vue' => 'Vue',
121 | 'livewire' => 'Livewire',
122 | ],
123 | default: 'none',
124 | )) {
125 | 'react' => $input->setOption('react', true),
126 | 'vue' => $input->setOption('vue', true),
127 | 'livewire' => $input->setOption('livewire', true),
128 | default => null,
129 | };
130 |
131 | if ($this->usingLaravelStarterKit($input)) {
132 | match (select(
133 | label: 'Which authentication provider do you prefer?',
134 | options: [
135 | 'laravel' => "Laravel's built-in authentication",
136 | 'workos' => 'WorkOS (Requires WorkOS account)',
137 | ],
138 | default: 'laravel',
139 | )) {
140 | 'laravel' => $input->setOption('workos', false),
141 | 'workos' => $input->setOption('workos', true),
142 | default => null,
143 | };
144 | }
145 |
146 | if ($input->getOption('livewire') && ! $input->getOption('workos')) {
147 | $input->setOption('livewire-class-components', ! confirm(
148 | label: 'Would you like to use Laravel Volt?',
149 | default: true,
150 | ));
151 | }
152 | }
153 |
154 | if (! $input->getOption('phpunit') && ! $input->getOption('pest')) {
155 | $input->setOption('pest', select(
156 | label: 'Which testing framework do you prefer?',
157 | options: ['Pest', 'PHPUnit'],
158 | default: 'Pest',
159 | ) === 'Pest');
160 | }
161 | }
162 |
163 | /**
164 | * Ensure that the required PHP extensions are installed.
165 | *
166 | * @param \Symfony\Component\Console\Input\InputInterface $input
167 | * @param \Symfony\Component\Console\Output\OutputInterface $output
168 | * @return void
169 | *
170 | * @throws \RuntimeException
171 | */
172 | protected function ensureExtensionsAreAvailable(InputInterface $input, OutputInterface $output): void
173 | {
174 | $availableExtensions = get_loaded_extensions();
175 |
176 | $missingExtensions = collect([
177 | 'ctype',
178 | 'filter',
179 | 'hash',
180 | 'mbstring',
181 | 'openssl',
182 | 'session',
183 | 'tokenizer',
184 | ])->reject(fn ($extension) => in_array($extension, $availableExtensions));
185 |
186 | if ($missingExtensions->isEmpty()) {
187 | return;
188 | }
189 |
190 | throw new \RuntimeException(
191 | sprintf('The following PHP extensions are required but are not installed: %s', $missingExtensions->join(', ', ', and '))
192 | );
193 | }
194 |
195 | /**
196 | * Execute the command.
197 | *
198 | * @param \Symfony\Component\Console\Input\InputInterface $input
199 | * @param \Symfony\Component\Console\Output\OutputInterface $output
200 | * @return int
201 | */
202 | protected function execute(InputInterface $input, OutputInterface $output): int
203 | {
204 | $this->validateDatabaseOption($input);
205 |
206 | $name = rtrim($input->getArgument('name'), '/\\');
207 |
208 | $directory = $this->getInstallationDirectory($name);
209 |
210 | $this->composer = new Composer(new Filesystem(), $directory);
211 |
212 | $version = $this->getVersion($input);
213 |
214 | if (! $input->getOption('force')) {
215 | $this->verifyApplicationDoesntExist($directory);
216 | }
217 |
218 | if ($input->getOption('force') && $directory === '.') {
219 | throw new RuntimeException('Cannot use --force option when using current directory for installation!');
220 | }
221 |
222 | $composer = $this->findComposer();
223 | $phpBinary = $this->phpBinary();
224 |
225 | $createProjectCommand = $composer." create-project laravel/laravel \"$directory\" $version --remove-vcs --prefer-dist --no-scripts";
226 |
227 | $starterKit = $this->getStarterKit($input);
228 |
229 | if ($starterKit) {
230 | $createProjectCommand = $composer." create-project {$starterKit} \"{$directory}\" --stability=dev";
231 |
232 | if ($this->usingLaravelStarterKit($input) && $input->getOption('livewire-class-components')) {
233 | $createProjectCommand = str_replace(" {$starterKit} ", " {$starterKit}:dev-components ", $createProjectCommand);
234 | }
235 |
236 | if ($this->usingLaravelStarterKit($input) && $input->getOption('workos')) {
237 | $createProjectCommand = str_replace(" {$starterKit} ", " {$starterKit}:dev-workos ", $createProjectCommand);
238 | }
239 | }
240 |
241 | $commands = [
242 | $createProjectCommand,
243 | $composer." run post-root-package-install -d \"$directory\"",
244 | $phpBinary." \"$directory/artisan\" key:generate --ansi",
245 | ];
246 |
247 | if ($directory != '.' && $input->getOption('force')) {
248 | if (PHP_OS_FAMILY == 'Windows') {
249 | array_unshift($commands, "(if exist \"$directory\" rd /s /q \"$directory\")");
250 | } else {
251 | array_unshift($commands, "rm -rf \"$directory\"");
252 | }
253 | }
254 |
255 | if (PHP_OS_FAMILY != 'Windows') {
256 | $commands[] = "chmod 755 \"$directory/artisan\"";
257 | }
258 |
259 | if (($process = $this->runCommands($commands, $input, $output))->isSuccessful()) {
260 | if ($name !== '.') {
261 | $this->replaceInFile(
262 | 'APP_URL=http://localhost',
263 | 'APP_URL='.$this->generateAppUrl($name, $directory),
264 | $directory.'/.env'
265 | );
266 |
267 | [$database, $migrate] = $this->promptForDatabaseOptions($directory, $input);
268 |
269 | $this->configureDefaultDatabaseConnection($directory, $database, $name);
270 |
271 | if ($migrate) {
272 | if ($database === 'sqlite') {
273 | touch($directory.'/database/database.sqlite');
274 | }
275 |
276 | $commands = [
277 | trim(sprintf(
278 | $this->phpBinary().' artisan migrate %s',
279 | ! $input->isInteractive() ? '--no-interaction' : '',
280 | )),
281 | ];
282 |
283 | $this->runCommands($commands, $input, $output, workingPath: $directory);
284 | }
285 | }
286 |
287 | if ($input->getOption('git') || $input->getOption('github') !== false) {
288 | $this->createRepository($directory, $input, $output);
289 | }
290 |
291 | if ($input->getOption('pest')) {
292 | $this->installPest($directory, $input, $output);
293 | }
294 |
295 | if ($input->getOption('github') !== false) {
296 | $this->pushToGitHub($name, $directory, $input, $output);
297 | $output->writeln('');
298 | }
299 |
300 | $this->configureComposerDevScript($directory);
301 |
302 | if ($input->getOption('pest')) {
303 | $output->writeln('');
304 | }
305 |
306 | $runNpm = $input->getOption('npm');
307 |
308 | if (! $input->getOption('npm') && $input->isInteractive()) {
309 | $runNpm = confirm(
310 | label: 'Would you like to run npm install> and npm run build>?'
311 | );
312 | }
313 |
314 | if ($runNpm) {
315 | $this->runCommands(['npm install', 'npm run build'], $input, $output, workingPath: $directory);
316 | }
317 |
318 | $output->writeln(" INFO > Application ready in [{$name}]>. You can start your local development using:".PHP_EOL);
319 | $output->writeln('➜> cd '.$name.'>');
320 |
321 | if (! $runNpm) {
322 | $output->writeln('➜> npm install && npm run build>');
323 | }
324 |
325 | if ($this->isParkedOnHerdOrValet($directory)) {
326 | $url = $this->generateAppUrl($name, $directory);
327 | $output->writeln('➜> Open: '.$url.'>');
328 | } else {
329 | $output->writeln('➜> composer run dev>');
330 | }
331 |
332 | $output->writeln('');
333 | $output->writeln(' New to Laravel? Check out our documentation>. Build something amazing!>');
334 | $output->writeln('');
335 | }
336 |
337 | return $process->getExitCode();
338 | }
339 |
340 | /**
341 | * Return the local machine's default Git branch if set or default to `main`.
342 | *
343 | * @return string
344 | */
345 | protected function defaultBranch()
346 | {
347 | $process = new Process(['git', 'config', '--global', 'init.defaultBranch']);
348 |
349 | $process->run();
350 |
351 | $output = trim($process->getOutput());
352 |
353 | return $process->isSuccessful() && $output ? $output : 'main';
354 | }
355 |
356 | /**
357 | * Configure the default database connection.
358 | *
359 | * @param string $directory
360 | * @param string $database
361 | * @param string $name
362 | * @return void
363 | */
364 | protected function configureDefaultDatabaseConnection(string $directory, string $database, string $name)
365 | {
366 | $this->pregReplaceInFile(
367 | '/DB_CONNECTION=.*/',
368 | 'DB_CONNECTION='.$database,
369 | $directory.'/.env'
370 | );
371 |
372 | $this->pregReplaceInFile(
373 | '/DB_CONNECTION=.*/',
374 | 'DB_CONNECTION='.$database,
375 | $directory.'/.env.example'
376 | );
377 |
378 | if ($database === 'sqlite') {
379 | $environment = file_get_contents($directory.'/.env');
380 |
381 | // If database options aren't commented, comment them for SQLite...
382 | if (! str_contains($environment, '# DB_HOST=127.0.0.1')) {
383 | $this->commentDatabaseConfigurationForSqlite($directory);
384 |
385 | return;
386 | }
387 |
388 | return;
389 | }
390 |
391 | // Any commented database configuration options should be uncommented when not on SQLite...
392 | $this->uncommentDatabaseConfiguration($directory);
393 |
394 | $defaultPorts = [
395 | 'pgsql' => '5432',
396 | 'sqlsrv' => '1433',
397 | ];
398 |
399 | if (isset($defaultPorts[$database])) {
400 | $this->replaceInFile(
401 | 'DB_PORT=3306',
402 | 'DB_PORT='.$defaultPorts[$database],
403 | $directory.'/.env'
404 | );
405 |
406 | $this->replaceInFile(
407 | 'DB_PORT=3306',
408 | 'DB_PORT='.$defaultPorts[$database],
409 | $directory.'/.env.example'
410 | );
411 | }
412 |
413 | $this->replaceInFile(
414 | 'DB_DATABASE=laravel',
415 | 'DB_DATABASE='.str_replace('-', '_', strtolower($name)),
416 | $directory.'/.env'
417 | );
418 |
419 | $this->replaceInFile(
420 | 'DB_DATABASE=laravel',
421 | 'DB_DATABASE='.str_replace('-', '_', strtolower($name)),
422 | $directory.'/.env.example'
423 | );
424 | }
425 |
426 | /**
427 | * Determine if the application is using Laravel 11 or newer.
428 | *
429 | * @param string $directory
430 | * @return bool
431 | */
432 | public function usingLaravelVersionOrNewer(int $usingVersion, string $directory): bool
433 | {
434 | $version = json_decode(file_get_contents($directory.'/composer.json'), true)['require']['laravel/framework'];
435 | $version = str_replace('^', '', $version);
436 | $version = explode('.', $version)[0];
437 |
438 | return $version >= $usingVersion;
439 | }
440 |
441 | /**
442 | * Comment the irrelevant database configuration entries for SQLite applications.
443 | *
444 | * @param string $directory
445 | * @return void
446 | */
447 | protected function commentDatabaseConfigurationForSqlite(string $directory): void
448 | {
449 | $defaults = [
450 | 'DB_HOST=127.0.0.1',
451 | 'DB_PORT=3306',
452 | 'DB_DATABASE=laravel',
453 | 'DB_USERNAME=root',
454 | 'DB_PASSWORD=',
455 | ];
456 |
457 | $this->replaceInFile(
458 | $defaults,
459 | collect($defaults)->map(fn ($default) => "# {$default}")->all(),
460 | $directory.'/.env'
461 | );
462 |
463 | $this->replaceInFile(
464 | $defaults,
465 | collect($defaults)->map(fn ($default) => "# {$default}")->all(),
466 | $directory.'/.env.example'
467 | );
468 | }
469 |
470 | /**
471 | * Uncomment the relevant database configuration entries for non SQLite applications.
472 | *
473 | * @param string $directory
474 | * @return void
475 | */
476 | protected function uncommentDatabaseConfiguration(string $directory)
477 | {
478 | $defaults = [
479 | '# DB_HOST=127.0.0.1',
480 | '# DB_PORT=3306',
481 | '# DB_DATABASE=laravel',
482 | '# DB_USERNAME=root',
483 | '# DB_PASSWORD=',
484 | ];
485 |
486 | $this->replaceInFile(
487 | $defaults,
488 | collect($defaults)->map(fn ($default) => substr($default, 2))->all(),
489 | $directory.'/.env'
490 | );
491 |
492 | $this->replaceInFile(
493 | $defaults,
494 | collect($defaults)->map(fn ($default) => substr($default, 2))->all(),
495 | $directory.'/.env.example'
496 | );
497 | }
498 |
499 | /**
500 | * Determine the default database connection.
501 | *
502 | * @param string $directory
503 | * @param \Symfony\Component\Console\Input\InputInterface $input
504 | * @return array
505 | */
506 | protected function promptForDatabaseOptions(string $directory, InputInterface $input)
507 | {
508 | $defaultDatabase = collect(
509 | $databaseOptions = $this->databaseOptions()
510 | )->keys()->first();
511 |
512 | if ($this->usingStarterKit($input)) {
513 | // Starter kits will already be migrated in post composer create-project command...
514 | $migrate = false;
515 |
516 | $input->setOption('database', 'sqlite');
517 | }
518 |
519 | if (! $input->getOption('database') && $input->isInteractive()) {
520 | $input->setOption('database', select(
521 | label: 'Which database will your application use?',
522 | options: $databaseOptions,
523 | default: $defaultDatabase,
524 | ));
525 |
526 | if ($input->getOption('database') !== 'sqlite') {
527 | $migrate = confirm(
528 | label: 'Default database updated. Would you like to run the default database migrations?'
529 | );
530 | } else {
531 | $migrate = true;
532 | }
533 | }
534 |
535 | return [$input->getOption('database') ?? $defaultDatabase, $migrate ?? $input->hasOption('database')];
536 | }
537 |
538 | /**
539 | * Get the available database options.
540 | *
541 | * @return array
542 | */
543 | protected function databaseOptions(): array
544 | {
545 | return collect([
546 | 'sqlite' => ['SQLite', extension_loaded('pdo_sqlite')],
547 | 'mysql' => ['MySQL', extension_loaded('pdo_mysql')],
548 | 'mariadb' => ['MariaDB', extension_loaded('pdo_mysql')],
549 | 'pgsql' => ['PostgreSQL', extension_loaded('pdo_pgsql')],
550 | 'sqlsrv' => ['SQL Server', extension_loaded('pdo_sqlsrv')],
551 | ])
552 | ->sortBy(fn ($database) => $database[1] ? 0 : 1)
553 | ->map(fn ($database) => $database[0].($database[1] ? '' : ' (Missing PDO extension)'))
554 | ->all();
555 | }
556 |
557 | /**
558 | * Validate the database driver input.
559 | *
560 | * @param \Symfony\Components\Console\Input\InputInterface $input
561 | */
562 | protected function validateDatabaseOption(InputInterface $input)
563 | {
564 | if ($input->getOption('database') && ! in_array($input->getOption('database'), $drivers = ['mysql', 'mariadb', 'pgsql', 'sqlite', 'sqlsrv'])) {
565 | throw new \InvalidArgumentException("Invalid database driver [{$input->getOption('database')}]. Valid options are: ".implode(', ', $drivers).'.');
566 | }
567 | }
568 |
569 | /**
570 | * Install Pest into the application.
571 | *
572 | * @param \Symfony\Component\Console\Input\InputInterface $input
573 | * @param \Symfony\Component\Console\Output\OutputInterface $output
574 | * @return void
575 | */
576 | protected function installPest(string $directory, InputInterface $input, OutputInterface $output)
577 | {
578 | $composerBinary = $this->findComposer();
579 |
580 | $commands = [
581 | $composerBinary.' remove phpunit/phpunit --dev --no-update',
582 | $composerBinary.' require pestphp/pest pestphp/pest-plugin-laravel --no-update --dev',
583 | $composerBinary.' update',
584 | $this->phpBinary().' ./vendor/bin/pest --init',
585 | ];
586 |
587 | $commands[] = $composerBinary.' require pestphp/pest-plugin-drift --dev';
588 | $commands[] = $this->phpBinary().' ./vendor/bin/pest --drift';
589 | $commands[] = $composerBinary.' remove pestphp/pest-plugin-drift --dev';
590 |
591 | $this->runCommands($commands, $input, $output, workingPath: $directory, env: [
592 | 'PEST_NO_SUPPORT' => 'true',
593 | ]);
594 |
595 | if ($this->usingStarterKit($input)) {
596 | $this->replaceInFile(
597 | './vendor/bin/phpunit',
598 | './vendor/bin/pest',
599 | $directory.'/.github/workflows/tests.yml',
600 | );
601 |
602 | $contents = file_get_contents("$directory/tests/Pest.php");
603 |
604 | $contents = str_replace(
605 | " // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)",
606 | " ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)",
607 | $contents,
608 | );
609 |
610 | file_put_contents("$directory/tests/Pest.php", $contents);
611 |
612 | $directoryIterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("$directory/tests"));
613 |
614 | foreach ($directoryIterator as $testFile) {
615 | if ($testFile->isDir()) {
616 | continue;
617 | }
618 |
619 | $contents = file_get_contents($testFile);
620 |
621 | file_put_contents(
622 | $testFile,
623 | str_replace("\n\nuses(\Illuminate\Foundation\Testing\RefreshDatabase::class);", '', $contents),
624 | );
625 | }
626 | }
627 |
628 | $this->commitChanges('Install Pest', $directory, $input, $output);
629 | }
630 |
631 | /**
632 | * Create a Git repository and commit the base Laravel skeleton.
633 | *
634 | * @param string $directory
635 | * @param \Symfony\Component\Console\Input\InputInterface $input
636 | * @param \Symfony\Component\Console\Output\OutputInterface $output
637 | * @return void
638 | */
639 | protected function createRepository(string $directory, InputInterface $input, OutputInterface $output)
640 | {
641 | $branch = $input->getOption('branch') ?: $this->defaultBranch();
642 |
643 | $commands = [
644 | 'git init -q',
645 | 'git add .',
646 | 'git commit -q -m "Set up a fresh Laravel app"',
647 | "git branch -M {$branch}",
648 | ];
649 |
650 | $this->runCommands($commands, $input, $output, workingPath: $directory);
651 | }
652 |
653 | /**
654 | * Commit any changes in the current working directory.
655 | *
656 | * @param string $message
657 | * @param string $directory
658 | * @param \Symfony\Component\Console\Input\InputInterface $input
659 | * @param \Symfony\Component\Console\Output\OutputInterface $output
660 | * @return void
661 | */
662 | protected function commitChanges(string $message, string $directory, InputInterface $input, OutputInterface $output)
663 | {
664 | if (! $input->getOption('git') && $input->getOption('github') === false) {
665 | return;
666 | }
667 |
668 | $commands = [
669 | 'git add .',
670 | "git commit -q -m \"$message\"",
671 | ];
672 |
673 | $this->runCommands($commands, $input, $output, workingPath: $directory);
674 | }
675 |
676 | /**
677 | * Create a GitHub repository and push the git log to it.
678 | *
679 | * @param string $name
680 | * @param string $directory
681 | * @param \Symfony\Component\Console\Input\InputInterface $input
682 | * @param \Symfony\Component\Console\Output\OutputInterface $output
683 | * @return void
684 | */
685 | protected function pushToGitHub(string $name, string $directory, InputInterface $input, OutputInterface $output)
686 | {
687 | $process = new Process(['gh', 'auth', 'status']);
688 | $process->run();
689 |
690 | if (! $process->isSuccessful()) {
691 | $output->writeln(' WARN > Make sure the "gh" CLI tool is installed and that you\'re authenticated to GitHub. Skipping...'.PHP_EOL);
692 |
693 | return;
694 | }
695 |
696 | $name = $input->getOption('organization') ? $input->getOption('organization')."/$name" : $name;
697 | $flags = $input->getOption('github') ?: '--private';
698 |
699 | $commands = [
700 | "gh repo create {$name} --source=. --push {$flags}",
701 | ];
702 |
703 | $this->runCommands($commands, $input, $output, workingPath: $directory, env: ['GIT_TERMINAL_PROMPT' => 0]);
704 | }
705 |
706 | /**
707 | * Configure the Composer "dev" script.
708 | *
709 | * @param string $directory
710 | * @return void
711 | */
712 | protected function configureComposerDevScript(string $directory): void
713 | {
714 | $this->composer->modify(function (array $content) {
715 | if (windows_os()) {
716 | $content['scripts']['dev'] = [
717 | 'Composer\\Config::disableProcessTimeout',
718 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names='server,queue,vite'",
719 | ];
720 | }
721 |
722 | return $content;
723 | });
724 | }
725 |
726 | /**
727 | * Verify that the application does not already exist.
728 | *
729 | * @param string $directory
730 | * @return void
731 | */
732 | protected function verifyApplicationDoesntExist($directory)
733 | {
734 | if ((is_dir($directory) || is_file($directory)) && $directory != getcwd()) {
735 | throw new RuntimeException('Application already exists!');
736 | }
737 | }
738 |
739 | /**
740 | * Generate a valid APP_URL for the given application name.
741 | *
742 | * @param string $name
743 | * @param string $directory
744 | * @return string
745 | */
746 | protected function generateAppUrl($name, $directory)
747 | {
748 | if (! $this->isParkedOnHerdOrValet($directory)) {
749 | return 'http://localhost:8000';
750 | }
751 |
752 | $hostname = mb_strtolower($name).'.'.$this->getTld();
753 |
754 | return $this->canResolveHostname($hostname) ? 'http://'.$hostname : 'http://localhost';
755 | }
756 |
757 | /**
758 | * Get the starter kit repository, if any.
759 | *
760 | * @param \Symfony\Component\Console\Input\InputInterface $input
761 | * @return string|null
762 | */
763 | protected function getStarterKit(InputInterface $input): ?string
764 | {
765 | return match (true) {
766 | $input->getOption('react') => 'laravel/react-starter-kit',
767 | $input->getOption('vue') => 'laravel/vue-starter-kit',
768 | $input->getOption('livewire') => 'laravel/livewire-starter-kit',
769 | default => $input->getOption('using'),
770 | };
771 | }
772 |
773 | /**
774 | * Determine if a Laravel first-party starter kit has been chosen.
775 | *
776 | * @param \Symfony\Component\Console\Input\InputInterface $input
777 | * @return bool
778 | */
779 | protected function usingLaravelStarterKit(InputInterface $input): bool
780 | {
781 | return $this->usingStarterKit($input) &&
782 | str_starts_with($this->getStarterKit($input), 'laravel/');
783 | }
784 |
785 | /**
786 | * Determine if a starter kit is being used.
787 | *
788 | * @param \Symfony\Component\Console\Input\InputInterface $input
789 | * @return bool
790 | */
791 | protected function usingStarterKit(InputInterface $input)
792 | {
793 | return $input->getOption('react') || $input->getOption('vue') || $input->getOption('livewire') || $input->getOption('using');
794 | }
795 |
796 | /**
797 | * Get the TLD for the application.
798 | *
799 | * @return string
800 | */
801 | protected function getTld()
802 | {
803 | return $this->runOnValetOrHerd('tld') ?: 'test';
804 | }
805 |
806 | /**
807 | * Determine whether the given hostname is resolvable.
808 | *
809 | * @param string $hostname
810 | * @return bool
811 | */
812 | protected function canResolveHostname($hostname)
813 | {
814 | return gethostbyname($hostname.'.') !== $hostname.'.';
815 | }
816 |
817 | /**
818 | * Get the installation directory.
819 | *
820 | * @param string $name
821 | * @return string
822 | */
823 | protected function getInstallationDirectory(string $name)
824 | {
825 | return $name !== '.' ? getcwd().'/'.$name : '.';
826 | }
827 |
828 | /**
829 | * Get the version that should be downloaded.
830 | *
831 | * @param \Symfony\Component\Console\Input\InputInterface $input
832 | * @return string
833 | */
834 | protected function getVersion(InputInterface $input)
835 | {
836 | if ($input->getOption('dev')) {
837 | return 'dev-master';
838 | }
839 |
840 | return '';
841 | }
842 |
843 | /**
844 | * Get the composer command for the environment.
845 | *
846 | * @return string
847 | */
848 | protected function findComposer()
849 | {
850 | return implode(' ', $this->composer->findComposer());
851 | }
852 |
853 | /**
854 | * Get the path to the appropriate PHP binary.
855 | *
856 | * @return string
857 | */
858 | protected function phpBinary()
859 | {
860 | $phpBinary = function_exists('Illuminate\Support\php_binary')
861 | ? \Illuminate\Support\php_binary()
862 | : (new PhpExecutableFinder)->find(false);
863 |
864 | return $phpBinary !== false
865 | ? ProcessUtils::escapeArgument($phpBinary)
866 | : 'php';
867 | }
868 |
869 | /**
870 | * Run the given commands.
871 | *
872 | * @param array $commands
873 | * @param \Symfony\Component\Console\Input\InputInterface $input
874 | * @param \Symfony\Component\Console\Output\OutputInterface $output
875 | * @param string|null $workingPath
876 | * @param array $env
877 | * @return \Symfony\Component\Process\Process
878 | */
879 | protected function runCommands($commands, InputInterface $input, OutputInterface $output, ?string $workingPath = null, array $env = [])
880 | {
881 | if (! $output->isDecorated()) {
882 | $commands = array_map(function ($value) {
883 | if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) {
884 | return $value;
885 | }
886 |
887 | return $value.' --no-ansi';
888 | }, $commands);
889 | }
890 |
891 | if ($input->getOption('quiet')) {
892 | $commands = array_map(function ($value) {
893 | if (Str::startsWith($value, ['chmod', 'git', $this->phpBinary().' ./vendor/bin/pest'])) {
894 | return $value;
895 | }
896 |
897 | return $value.' --quiet';
898 | }, $commands);
899 | }
900 |
901 | $process = Process::fromShellCommandline(implode(' && ', $commands), $workingPath, $env, null, null);
902 |
903 | if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) {
904 | try {
905 | $process->setTty(true);
906 | } catch (RuntimeException $e) {
907 | $output->writeln(' WARN > '.$e->getMessage().PHP_EOL);
908 | }
909 | }
910 |
911 | $process->run(function ($type, $line) use ($output) {
912 | $output->write(' '.$line);
913 | });
914 |
915 | return $process;
916 | }
917 |
918 | /**
919 | * Replace the given file.
920 | *
921 | * @param string $replace
922 | * @param string $file
923 | * @return void
924 | */
925 | protected function replaceFile(string $replace, string $file)
926 | {
927 | $stubs = dirname(__DIR__).'/stubs';
928 |
929 | file_put_contents(
930 | $file,
931 | file_get_contents("$stubs/$replace"),
932 | );
933 | }
934 |
935 | /**
936 | * Replace the given string in the given file.
937 | *
938 | * @param string|array $search
939 | * @param string|array $replace
940 | * @param string $file
941 | * @return void
942 | */
943 | protected function replaceInFile(string|array $search, string|array $replace, string $file)
944 | {
945 | file_put_contents(
946 | $file,
947 | str_replace($search, $replace, file_get_contents($file))
948 | );
949 | }
950 |
951 | /**
952 | * Replace the given string in the given file using regular expressions.
953 | *
954 | * @param string|array $search
955 | * @param string|array $replace
956 | * @param string $file
957 | * @return void
958 | */
959 | protected function pregReplaceInFile(string $pattern, string $replace, string $file)
960 | {
961 | file_put_contents(
962 | $file,
963 | preg_replace($pattern, $replace, file_get_contents($file))
964 | );
965 | }
966 |
967 | /**
968 | * Delete the given file.
969 | *
970 | * @param string $file
971 | * @return void
972 | */
973 | protected function deleteFile(string $file)
974 | {
975 | unlink($file);
976 | }
977 | }
978 |
--------------------------------------------------------------------------------