Repository: appkr/l5essential Branch: master Commit: 428a0051c85a Files: 287 Total size: 2.8 MB Directory structure: gitextract_5wn34sgq/ ├── .bowerrc ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Envoy.blade.php ├── LICENSE ├── Vagrantfile ├── apiary.apib ├── app/ │ ├── Article.php │ ├── Attachment.php │ ├── AuthorTrait.php │ ├── Comment.php │ ├── Console/ │ │ ├── Commands/ │ │ │ ├── BackupDb.php │ │ │ ├── ClearLog.php │ │ │ ├── Inspire.php │ │ │ ├── PruneRelease.php │ │ │ └── UpdateLessonsTable.php │ │ └── Kernel.php │ ├── Events/ │ │ ├── ArticleConsumed.php │ │ ├── Event.php │ │ └── ModelChanged.php │ ├── Exceptions/ │ │ └── Handler.php │ ├── Http/ │ │ ├── Controllers/ │ │ │ ├── Api/ │ │ │ │ ├── PasswordsController.php │ │ │ │ ├── SessionsController.php │ │ │ │ ├── UsersController.php │ │ │ │ ├── V1/ │ │ │ │ │ ├── ArticlesController.php │ │ │ │ │ ├── CommentsController.php │ │ │ │ │ └── WelcomeController.php │ │ │ │ └── WelcomeController.php │ │ │ ├── ArticlesController.php │ │ │ ├── AttachmentsController.php │ │ │ ├── Cacheable.php │ │ │ ├── CommentsController.php │ │ │ ├── Controller.php │ │ │ ├── LessonsController.php │ │ │ ├── PasswordsController.php │ │ │ ├── SessionsController.php │ │ │ ├── SocialController.php │ │ │ ├── UsersController.php │ │ │ └── WelcomeController.php │ │ ├── Kernel.php │ │ ├── Middleware/ │ │ │ ├── Authenticate.php │ │ │ ├── AuthorOnly.php │ │ │ ├── EncryptCookies.php │ │ │ ├── GetUserFromToken.php │ │ │ ├── ObfuscateId.php │ │ │ ├── RedirectIfAuthenticated.php │ │ │ ├── RefreshToken.php │ │ │ ├── ThrottleApiRequests.php │ │ │ └── VerifyCsrfToken.php │ │ ├── Requests/ │ │ │ ├── ArticlesRequest.php │ │ │ ├── FilterArticlesRequest.php │ │ │ └── Request.php │ │ └── routes.php │ ├── Jobs/ │ │ └── Job.php │ ├── Lesson.php │ ├── Listeners/ │ │ ├── .gitkeep │ │ ├── CacheHandler.php │ │ ├── CommentsHandler.php │ │ ├── UserEventsHandler.php │ │ └── ViewCountHandler.php │ ├── Model.php │ ├── Policies/ │ │ └── .gitkeep │ ├── Providers/ │ │ ├── AppServiceProvider.php │ │ ├── AuthServiceProvider.php │ │ ├── EventServiceProvider.php │ │ └── RouteServiceProvider.php │ ├── Reporters/ │ │ ├── ErrorReport.php │ │ └── MonologSlackReport.php │ ├── Repositories/ │ │ ├── LessonRepository.php │ │ ├── MarkdownRepository.php │ │ └── RepositoryInterface.php │ ├── Services/ │ │ └── Markdown.php │ ├── Tag.php │ ├── Transformers/ │ │ ├── ArticleTransformer.php │ │ ├── AttachmentTransformer.php │ │ ├── CommentTransformer.php │ │ ├── TagTransformer.php │ │ └── UserTransformer.php │ ├── User.php │ ├── Vote.php │ └── helpers.php ├── artisan ├── bootstrap/ │ ├── app.php │ ├── autoload.php │ └── cache/ │ └── .gitignore ├── bower.json ├── composer.json ├── config/ │ ├── api.php │ ├── app.php │ ├── auth.php │ ├── broadcasting.php │ ├── cache.php │ ├── compile.php │ ├── cors.php │ ├── database.php │ ├── filesystems.php │ ├── icons.php │ ├── jwt.php │ ├── mail.php │ ├── project.php │ ├── queue.php │ ├── roles.php │ ├── services.php │ ├── session.php │ ├── slack.php │ └── view.php ├── database/ │ ├── .gitignore │ ├── factories/ │ │ └── ModelFactory.php │ ├── migrations/ │ │ ├── .gitkeep │ │ ├── 2014_10_12_000000_create_users_table.php │ │ ├── 2014_10_12_100000_create_password_resets_table.php │ │ ├── 2015_01_15_105324_create_roles_table.php │ │ ├── 2015_01_15_114412_create_role_user_table.php │ │ ├── 2015_01_26_115212_create_permissions_table.php │ │ ├── 2015_01_26_115523_create_permission_role_table.php │ │ ├── 2015_02_09_132439_create_permission_user_table.php │ │ ├── 2015_11_20_062500_create_comments_table.php │ │ ├── 2015_11_20_062513_create_articles_table.php │ │ ├── 2015_11_20_062601_create_tags_table.php │ │ ├── 2015_11_20_062613_create_attachments_table.php │ │ ├── 2015_11_20_062846_create_article_tag_table.php │ │ ├── 2015_12_09_165930_create_lessons_table.php │ │ └── 2015_12_10_151357_create_votes_table.php │ └── seeds/ │ ├── .gitkeep │ └── DatabaseSeeder.php ├── gulpfile.js ├── lessons/ │ ├── 01-welcome.md │ ├── 02-hello-laravel.md │ ├── 02-install-homestead-osx.md │ ├── 02-install-homestead-windows.md │ ├── 02-install-on-windows.md │ ├── 03-configuration.md │ ├── 04-routing-basics.md │ ├── 05-pass-data-to-view.md │ ├── 06-blade-101.md │ ├── 07-blade-201.md │ ├── 08-raw-queries.md │ ├── 09-query-builder.md │ ├── 10-eloquent.md │ ├── 11-migration.md │ ├── 12-controller.md │ ├── 13-restful-resource-controller.md │ ├── 14-named-routes.md │ ├── 15-nested-resources.md │ ├── 16-authentication.md │ ├── 17-authentication-201.md │ ├── 18-eloquent-relationships.md │ ├── 19-seeder.md │ ├── 20-1-pagination.md │ ├── 20-eager-loading.md │ ├── 21-mail.md │ ├── 22-events.md │ ├── 23-validation.md │ ├── 24-exception-handling.md │ ├── 25-composer.md │ ├── 26-document-model.md │ ├── 27-document-controller.md │ ├── 28-cache.md │ ├── 29-elixir.md │ ├── 30-final-touch.md │ ├── 31-forum-features.md │ ├── 32-login.md │ ├── 32n33-auth-refactoring.md │ ├── 33-social-login.md │ ├── 34-role.md │ ├── 35-locale.md │ ├── 36-models.md │ ├── 37-articles.md │ ├── 38-tags.md │ ├── 39-attachments.md │ ├── 40-comments.md │ ├── 41-ui-makeup.md │ ├── 42-be-makeup.md │ ├── 43-change-note.md │ ├── 44-api-basic.md │ ├── 45-api-big-picture.md │ ├── 46-jwt.md │ ├── 47-dry-refactoring.md │ ├── 48-all-is-bad.md │ ├── 49-rate-limit.md │ ├── 50-id-obfuscation.md │ ├── 51-cors.md │ ├── 52-caching.md │ ├── 53-partial-response.md │ ├── 54-api-docs.md │ ├── 999-code-release.md │ ├── INDEX.md │ └── images/ │ ├── 02-hello-laravel-img-01.pptx │ ├── 02-hello-laravel-img-03.pptx │ ├── 32n33-auth-refactoring-img-02.pptx │ └── 46-jwt-img-01.pptx ├── package.json ├── phpunit.xml ├── public/ │ ├── .htaccess │ ├── attachments/ │ │ └── .gitignore │ ├── build/ │ │ ├── css/ │ │ │ └── app-4cd4d601dd.css │ │ ├── fonts/ │ │ │ └── FontAwesome.otf │ │ ├── js/ │ │ │ ├── app-038b8ad709.js │ │ │ └── app-6c3ef62a70.js │ │ └── rev-manifest.json │ ├── index.php │ └── robots.txt ├── readme.md ├── resources/ │ ├── assets/ │ │ ├── js/ │ │ │ └── app.js │ │ └── sass/ │ │ ├── _auth.scss │ │ ├── _commons.scss │ │ ├── _forum.scss │ │ ├── _landing.scss │ │ ├── _lessons.scss │ │ ├── _mediaquery.scss │ │ └── app.scss │ ├── lang/ │ │ ├── en/ │ │ │ ├── auth.php │ │ │ ├── common.php │ │ │ ├── errors.php │ │ │ ├── forum.php │ │ │ ├── lessons.php │ │ │ ├── pagination.php │ │ │ ├── passwords.php │ │ │ └── validation.php │ │ └── ko/ │ │ ├── auth.php │ │ ├── common.php │ │ ├── errors.php │ │ ├── forum.php │ │ ├── lessons.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ └── validation.php │ └── views/ │ ├── articles/ │ │ ├── create.blade.php │ │ ├── edit.blade.php │ │ ├── index.blade.php │ │ ├── partial/ │ │ │ ├── article.blade.php │ │ │ ├── form.blade.php │ │ │ └── search.blade.php │ │ └── show.blade.php │ ├── attachments/ │ │ └── partial/ │ │ └── list.blade.php │ ├── comments/ │ │ ├── index.blade.php │ │ └── partial/ │ │ ├── best.blade.php │ │ ├── comment.blade.php │ │ ├── control.blade.php │ │ ├── create.blade.php │ │ ├── edit.blade.php │ │ └── login.blade.php │ ├── emails/ │ │ ├── new-comment.blade.php │ │ └── password.blade.php │ ├── errors/ │ │ ├── 503.blade.php │ │ └── notice.blade.php │ ├── home.blade.php │ ├── layouts/ │ │ ├── master.blade.php │ │ └── partial/ │ │ ├── flash_message.blade.php │ │ ├── flash_modal.blade.php │ │ ├── footer.blade.php │ │ ├── markdown.blade.php │ │ ├── navigation.blade.php │ │ └── tracker.blade.php │ ├── lessons/ │ │ ├── partial/ │ │ │ └── pager.blade.php │ │ └── show.blade.php │ ├── passwords/ │ │ ├── remind.blade.php │ │ └── reset.blade.php │ ├── sessions/ │ │ └── create.blade.php │ ├── tags/ │ │ └── partial/ │ │ ├── index.blade.php │ │ └── list.blade.php │ ├── users/ │ │ ├── create.blade.php │ │ └── partial/ │ │ └── avatar.blade.php │ └── vendor/ │ ├── .gitkeep │ └── flash/ │ ├── message.blade.php │ └── modal.blade.php ├── server.php ├── storage/ │ ├── app/ │ │ └── .gitignore │ ├── backup/ │ │ └── .gitignore │ ├── framework/ │ │ ├── .gitignore │ │ ├── cache/ │ │ │ └── .gitignore │ │ ├── sessions/ │ │ │ └── .gitignore │ │ └── views/ │ │ └── .gitignore │ └── logs/ │ └── .gitignore └── tests/ ├── TestCase.php └── integration/ └── Http/ └── Controllers/ ├── Api/ │ ├── ApiTest.php │ ├── PasswordsController.php │ ├── SessionsController.php │ ├── UsersController.php │ └── V1/ │ └── ArticlesController.php ├── AuthTest.php ├── SessionsController.php ├── UsersController.php └── WelcomeController.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .bowerrc ================================================ {"directory":"resources/assets/vendor","analytics":false} ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [*.php] indent_size = 4 [**.blade.php] indent_size = 2 ================================================ FILE: .gitattributes ================================================ * text=auto *.css linguist-vendored *.less linguist-vendored ================================================ FILE: .gitignore ================================================ .env .idea .DS_Store .phpstorm* _ide_helper* dredd.* git_* Homestead.* node_modules npm-debug.log /public/css /public/js /resources/assets/css /resources/assets/vendor /tests/database.sqlite /vendor ================================================ FILE: .travis.yml ================================================ language: php php: - 5.6 - 7.0 sudo: false install: - composer self-update - travis_retry composer install --no-interaction --no-scripts --prefer-source --dev before_script: - cp .env.example .env - touch tests/database.sqlite - php artisan key:generate - php artisan clear-compiled - php artisan optimize - php artisan cache:clear - php artisan migrate --env="testing" --database="sqlite" --force - php artisan db:seed --env="testing" --database="sqlite" --force script: vendor/bin/phpunit matrix: fast_finish: true ================================================ FILE: CONTRIBUTING.md ================================================ # Contribution Guide 이 강좌에 여러가지 방법으로 기여할 수 있습니다. - 강좌 오류 수정 - 코드 오류 수정 - ... 기여자 분들은 기여의 형태에 따라 [Contributors / Sponsors](https://github.com/appkr/l5essential#contributors--sponsors) 또는 [Contributor List](https://github.com/appkr/l5essential/graphs/contributors) 에 등록됩니다. 이 강좌는 독자 및 기여자 여러분들과 같이 만들어 가는 것입니다. 여러 분들의 기여 활동은 이 강좌를 접하는 많은 분들에게 도움이 될 것입니다. ## Pull Request - 코드는 [PSR-2 코딩 컨벤션](http://www.php-fig.org/psr/psr-2/) 을 지켜 주세요. - 수정 내용을 설명해 주세요. - Fork 한후 Topic Branch 를 만들어서 PR 을 보내 주세요. - PR 전에 최신 코드인 지를 확인해 주세요. (`$ git pull && git merge master`) - 단순 오류 수정이 아니라, 동작을 변경하는 코드라면 Test 를 포함해 주세요. ## Test 이 강좌의 코드는 Integration Test 를 포함하고 있습니다. 코드를 수정했다면 PR 전에 테스트를 실행해 주세요. PR 을 하시면 Travis CI 가 테스트를 한번 더 수행합니다. ```sh $ phpunit ``` 감사합니다. appkr. ================================================ FILE: Envoy.blade.php ================================================ #-------------------------------------------------------------------------- # List of tasks, that you can run... # e.g. envoy run hello #-------------------------------------------------------------------------- # # hello Check ssh connection # release Publish new release # list Show list of releases # checkout Checkout to the given release (must provide --release=/path/to/release) # prune Purge old releases (must provide --keep=n, where n is a number) # @servers(['web' => 'aws-seoul-deploy']) @setup $path = [ 'base' => '/home/deployer/www', 'docroot' => '/home/deployer/www/l5.appkr.kr', 'shared' => '/home/deployer/www/shared', 'release' => '/home/deployer/www/releases', ]; $required_dirs = [ $path['base'], $path['shared'], $path['release'], ]; $shared_item = [ '/home/deployer/www/shared/.env' => '.env', '/home/deployer/www/shared/storage' => 'storage', '/home/deployer/www/shared/cache' => 'bootstrap/cache', '/home/deployer/www/shared/attachments' => 'public/attachments', ]; $distribution = [ 'name' => 'release_' . date('YmdHis'), ]; $git = [ 'repo' => 'git@github.com:appkr/l5essential.git', ]; @endsetup @task('hello', ['on' => ['web']]) HOSTNAME=$(hostname); echo "Hello Envoy! Responding from $HOSTNAME"; @endtask @task('release', ['on' => ['web']]) {{--Create directories if not exists--}} @foreach ($required_dirs as $dir) if [ ! -d {{ $dir }} ]; then mkdir {{ $dir }}; chgrp -h -R www-data {{ $dir }}; fi; @endforeach {{--Download book keeping officer--}} if [ ! -f {{ $path['base'] }}/officer.php ]; then wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/officer.php -O {{ $path['base'] }}/officer.php; fi; {{--Fetch code from git--}} cd {{ $path['release'] }}; git clone -b master {{ $git['repo'] }} {{ $distribution['name'] }}; {{--Symlink shared directory to current release.--}} {{--e.g. storage, .env, user uploaded file storage, ...--}} cd {{ $path['release'] }}/{{ $distribution['name'] }}; @foreach($shared_item as $global => $local) [ -f {{ $local }} ] && rm {{ $local }}; [ -d {{ $local }} ] && rm -rf {{ $local }}; ln -nfs {{ $global }} {{ $local }}; chgrp -h -R www-data {{ $local }}; @endforeach {{--Run composer install--}} composer install --prefer-dist --no-scripts; php artisan clear-compiled; php artisan optimize; php artisan cache:clear; php artisan my:update-lesson; {{--Symlink current release to service directory.--}} ln -nfs {{ $path['release'] }}/{{ $distribution['name'] }} {{ $path['docroot'] }}; chgrp -h -R www-data {{ $path['docroot'] }}; {{--Set permission and change owner. Do one final more for safety.--}} chgrp -h -R www-data {{ $path['release'] }}/{{ $distribution['name'] }}; {{--Book keeping--}} php {{ $path['base'] }}/officer.php deploy {{ $path['release'] }}/{{ $distribution['name'] }}; {{--Restart web server.--}} sudo service nginx restart; sudo service php5-fpm restart; @endtask @task('prune', ['on' => 'web']) if [ ! -f {{ $path['base'] }}/officer.php ]; then echo '"officer.php" script not found.'; echo '\$ envoy run hire_officer'; exit 1; fi; @if (isset($keep) and $keep > 0) php {{ $path['base'] }}/officer.php prune {{ $keep }}; @else echo 'Must provide --keep=n, where n is a number.'; @endif @endtask @task('hire_officer', ['on' => 'web']) {{--Download "officer.php" to the server--}} wget https://raw.githubusercontent.com/appkr/envoy/master/scripts/officer.php -O {{ $path['base'] }}/officer.php; echo '"officer.php" is ready!'; @endtask @task('list', ['on' => 'web']) {{--Show the list of release--}} if [ ! -f {{ $path['base'] }}/officer.php ]; then echo '"officer.php" script not found.'; echo '\$ envoy run hire_officer'; exit 1; fi; php {{ $path['base'] }}/officer.php list; @endtask @task('checkout', ['on' => 'web']) {{--Checkout to the given release path--}} if [ ! -f {{ $path['base'] }}/officer.php ]; then echo '"officer.php" script not found.'; echo '\$ envoy run hire_officer'; exit 1; fi; @if (isset($release)) cd {{ $release }}; {{--Symlink shared directory to the given release.--}} @foreach($shared_item as $global => $local) [ -f {{ $local }} ] && rm {{ $local }}; [ -d {{ $local }} ] && rm -rf {{ $local }}; ln -nfs {{ $global }} {{ $local }}; chgrp -h -R www-data {{ $local }}; @endforeach {{--Symlink the given release to service directory.--}} ln -nfs {{ $release }} {{ $path['docroot'] }}; chgrp -h -R www-data {{ $path['docroot'] }}; {{--Book keeping--}} php {{ $path['base'] }}/officer.php checkout {{ $release }}; {{--Restart web server.--}} sudo service nginx restart; sudo service php5-fpm restart; @else echo 'Must provide --release=/full/path/to/release.'; @endif @endtask ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015 Appkr Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Vagrantfile ================================================ require 'json' require 'yaml' VAGRANTFILE_API_VERSION ||= "2" confDir = $confDir ||= File.expand_path("vendor/laravel/homestead", File.dirname(__FILE__)) homesteadYamlPath = "Homestead.yaml" homesteadJsonPath = "Homestead.json" afterScriptPath = "after.sh" aliasesPath = "aliases" require File.expand_path(confDir + '/scripts/homestead.rb') Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| if File.exists? aliasesPath then config.vm.provision "file", source: aliasesPath, destination: "~/.bash_aliases" end if File.exists? homesteadYamlPath then Homestead.configure(config, YAML::load(File.read(homesteadYamlPath))) elsif File.exists? homesteadJsonPath then Homestead.configure(config, JSON.parse(File.read(homesteadJsonPath))) end if File.exists? afterScriptPath then config.vm.provision "shell", path: afterScriptPath end end ================================================ FILE: apiary.apib ================================================ FORMAT: 1A HOST: http://api.appkr.kr # Welcome to the myProject Api myProject Api 에 오신 것을 환영합니다. myProject 에서는 포럼 Forum Api 를 제공합니다. 포럼을 사용하는 사용자를 식별하고 권한을 제어하기 위해 사용자 등록 및 로그인 Authentication 기능도 제공합니다. ## Base URL 모든 Api 요청은 아래 Base URL 을 이용합니다. ```http http://api.appkr.kr ``` ## Current Version 현재 버전은 v1 입니다. [Authentication](#authentication) 을 제외하고 모든 Api 요청에 `/v1` 이 포함되어야 합니다. ```http GET /v1 HTTP/1.1 Host: api.appkr.kr ``` ## Request 101 REST 원칙을 따릅니다. ### Reqeust Headers #### Accept (Content Negotiation) v1 에서는 JSON 응답만 지원합니다. ```http GET /v1 HTTP/1.1 Host: api.appkr.kr Accept: application/json ``` #### Accept-Language (Language Negoation) v1 에서는 ko_KR 만 지원합니다. ```http GET /v1 HTTP/1.1 Host: api.appkr.kr Accept-Language: ko-KR ``` ### Request Payload `multipart/form-data`, `application/x-www-form-urlencoded`, `application/json` 을 모두 지원합니다. `application/json` 사용을 권장합니다. ```http POST /v1/articles HTTP/1.1 Host: api.appkr.kr Content-type: application/json {"field": "value"} ``` ## Response 101 ### Collection Response Collection 에 해당하는 리소스를 요청하면 Pagination 이 포함된 응답을 받습니다. `data` 키 아래에 포함된 내용이 Collection 입니다. ```http GET /v1/articles HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```json HTTP/1.1 200 OK { "data": [ {"key": "value"}, {"key": "value"}, {"key": "value"}, {"key": "value"}, {"key": "value"} ], "meta": { "pagination": { "total": 20, "count": 5, "per_page": 5, "current_page": 1, "total_pages": 4, "links": { "next": "/v1/articles?page=2" } } } } ``` ### Instance Response Instance 에 해당하는 리소스를 요청하면 요청한 Instance 하나에 대한 응답을 받습니다. 이 응답은 JSON 키가 없습니다. ```http GET /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```json HTTP/1.1 200 OK { "id": 95280986, "title": "example title" } ``` ### Successful Response 리소스 생성, 수정, 삭제 등의 요청을 성공하면 성공 응답을 받습니다. ```http PUT /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json Authorization: Bearer header.payload.signagure {"title": "modified title"} ``` ```javascript HTTP/1.1 200 OK { "success": { "code": 200, "message": "Updated" } } ``` 성공 응답 목록은 아래와 같고, HTTP 응답 코드와 동일한 코드를 JSON 본문에도 포함합니다. Response Code|Description ---|--- 200|성공 201|새로운 리소스가 성공적으로 생성되었습니다. 204|요청한 리소스가 삭제되었습니다. ### Error Response 에러가 발생했을 때는 아래와 같은 응답을 받습니다. ```http PUT /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json Authorization: Bearer header.payload.signagure {"title": "modified title"} ``` ```javascript HTTP/1.1 403 Forbidden { "error": { "code": 403, "message": "Forbidden" } } ``` 오류 응답 목록은 아래와 같고, HTTP 응답 코드와 동일한 코드를 JSON 본문에도 포함합니다. Response Code|Description ---|--- 400|요청에 오류가 있습니다. 401|인증 오류입니다. Authorization 헤더를 확인해 주세요. 403|권한이 없습니다. Authorization 헤더로 넘긴 토큰에 해당하는 사용자가 요청하신 Action 을 수행할 권한이 없습니다. 404|요청한 리소스가 없습니다. 405|잘못된 HTTP 메소드를 사용했습니다. 422|유효성 검사 오류입니다. 요청 본문에 데이터가 형식에 맞는지 확인하세요. 429|Rate Limit 를 초과했습니다. 잠시 후 다시 시도하세요. 500|서버에 오류가 있습니다. [관리자](mailto:junwonkim@me.com) 에게 신고해 주세요. 503|서버에 트래픽이 폭주했거나, 서버 유지보수 작업 중입니다. ## Rate Limit [Authentication](#authentication) 요청은 1 분에 10 회 까지, 리소스 요청은 1분에 60 회까지 허용합니다. ```http GET /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 ``` ## Caching GET 동사를 이용한 요청은 `Etag` 헤더를 응답합니다. > 대부분의 HTTP 클라이언트 라이브러리가 자동으로 모든 처리를 해 줍니다.

> 다만, 여러분의 클라이언트가 `Etag` 기능을 지원하지 않는데, 캐싱의 이득을 보려면, 응답 받은 Etag 헤더를 파싱하여 요청 URL 과 응답 본문을 저장하고, 다음 요청시 `If-None-Match: Etag` 헤더를 전송해야 합니다. 아래 그림은 `Etag` 를 이용한 클라이언트-서버간 캐싱 동작 시퀀스 다이어그램입니다. ![](https://camo.githubusercontent.com/f5c800863972804dd7af6ef6a55343df3fb55b53/687474703a2f2f342e62702e626c6f6773706f742e636f6d2f2d4f6a3674794f74386167302f565934456b5878324f5a492f41414141414141414145492f37554a6e43503459384f452f733634302f65746167732e706e67) ### First Request & Response 처음으로 `Etag` 값을 받습니다. 클라이언트 저장소에 받은 `Etag` 값을 키로 요청 URL 과 응답 본문을 저장해 놓습니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)* ```http GET /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr ``` ```http HTTP/1.1 200 OK Etag: CtXS0QQlWJKY2dXe ``` ### Second-on Request & Response - **No change in server resources** 요청 URL 에 해당하는 `Etag` 값을 `If-None-Match` 헤더에 달아서 보냅니다. 응답을 보니 리소스의 내용이 변경되지 않았으므로, 클라이언트 저장소에 저장된 지난 번 응답 본문을 그대로 사용합니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)* ```http GET /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json If-None-Match: CtXS0QQlWJKY2dXe ``` ```http HTTP/1.1 304 Not Modified ``` ### Second-on Request & Response - **Server resource changed** 서버의 리소스가 변경되었습니다. 새로 받은 `Etag` 값과 응답 본문을 클라이언트 저장소에 저장합니다. *(대부분의 클라이언트 라이브러리가 자동으로 처리해 줍니다.)* ```http GET /v1/articles/95280986 HTTP/1.1 Host: api.appkr.kr Content-type: application/json If-Non-Match: CtXS0QQlWJKY2dXe ``` ```http HTTP/1.1 200 OK Etag: F8tG872adeIC3zlf ``` **`WHY`** 모바일 환경에서 응답속도 개선 및 사용자 요금 절감을 위해 꼭 필요합니다. ## Sub Resource Inclusion ### Signature `include` 쿼리스트링을 이용하면, 현재 요청하는 리소스의 자식 리소스의 Collection 또는 Instance 를 응답에 포함할 수 있습니다. 문법은 아래와 같습니다. ```http GET /v1/parent?include=child_resource:limit(limit|offset):sort(sort|order) HTTP/1.1 # 여러 개의 자식 리소스를 포함할 때는 array 필드나 콤마 (,) 를 이용할 수 있습니다. GET /v1/parent?include[]=child_resource_1&include[]=child_resource_2 HTTP/1.1 # --or-- GET /v1/parent?include[]=child_resource_1,child_resource_2 HTTP/1.1 ``` Keyword|Description ---|--- `child_resource`|자식 리소스의 이름입니다. (Article 부모 리소스에 대해서는 `comments`, `author`, `tags`, `attachments` 자식 리소스가 가용합니다.) `:`|파라미터를 구분하는 구분자입니다. `limit`|예약어 입니다. `comments`, `author`, `tags`, `attachments` 등 Collection 형태의 리소스에만 적용 가능합니다. `limit(offset\\limit)`|`limit` 반환 받을 Collection 개수 입니다. `offset` 건너뛸 Collection 개수입니다. **(기본값 `limit(3\\0)`)** `sort`|예약어 입니다. Collection 형태의 리소스에만 적용 가능합니다. `sort(sort\\order)`|`sort` 정렬 기준 필드로 현재는 `created`, `view_count` 두 개만 지원합니다. `order` 정렬 방향으로 `asc`, `desc` 를 쓸 수 있습니다. **(기본값 `sort(created\\desc)`)** **`알림`** 위 표에서 `\\` 는 Apiary 의 마크다운 컴파일 에러를 방지하기 위해 `|` 대신 넣은 문자입니다. ### Example 아래는 Article 리소스의 자식 리소스인 comments 와 authors 를 포함하는 예제 입니다. ```http GET /v1/articles/95280986?include=comments:limit(1|0):sort('created'|'asc') HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```javascript HTTP/1.1 200 OK { "id": 95280986, "title": "example title", "content_raw": "example content", "content_html": "

example content

", "created": "2015-12-19T10:37:42+0000", "view_count": 1, "link": { "rel": "self", "href": "/v1/articles/95280986" }, "comments": { "data": [ { "id": 95280986, "content_raw": "example comment", "content_html": "

example comment

", "created": "2015-12-19T10:37:42+0000", "vote": { "up": 1, "down": 1 }, "link": { "rel": "self", "href": "/v1/comments/95280986" }, "author": { "name": "John Doe", "email": "john@example.com", "avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6" } } ] }, "author": { "name": "John Doe", "email": "john@example.com", "avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6" }, "tags": [ "laravel", "eloquent" ], "attachments": 1 } ``` ## Partial Response ### Against Parent Resource `fields` 쿼리스트링을 이용하면, 응답 필드를 골라서 받을 수 있습니다. 필드는 콤마 (`,`) 로 구분하고, 필드간에 공백이 없도록 주의하십시오. ```http GET /v1/articles/95280986?fields=id,title,content_raw,author HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```javascript HTTP/1.1 200 OK { "id": 95280986, "title": "example title", "content_raw": "example content", "author": { "name": "John Doe", "email": "john@example.com", "avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6" } } ``` ### Against Sub Resource 역시 `fields` 쿼리스트링을 이용해서 자식 리소스의 응답 필드를 골라 받을 수 있습니다. 부모 리소스에서와 달리, 필드 구분에 파이프 문자 (`|`) 를 이용합니다. 필드 사이에 공백이 없도록 주의하십시오. ```http GET /v1/articles/95280986?fields=id,title,content_raw,author&include=comments:fields(id|created|vote) HTTP/1.1 Host: api.appkr.kr Content-type: application/json ``` ```javascript HTTP/1.1 200 OK { "id": 95280986, "title": "example title", "content_raw": "example content", "author": { "name": "John Doe", "email": "john@example.com", "avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6" }, "comments": { "data": [ { "id": 95280986, "created": "2015-12-19T10:37:42+0000", "vote": { "up": 1, "down": 1 } } ] }, } ``` # group Welcome to myProject Api v1 myProject Api v1 에 오신 것을 환영합니다. ## Welcoming the Api v1 [/v1] ### Hello myProject Api v1 [GET] - request - headers Accept: application/json - response 200 (application/json) { "name": "myProject Api", "message": "Welcome to myProject Api. This is a base endpoint of version 1.", "version": "v1", "links": [ { "rel": "self", "href": "/v1" }, { "rel": "api.users.store", "href": "/auth/register" }, { "rel": "api.sessions.store", "href": "/auth/login" }, { "rel": "api.v1.docs", "href": "/v1/docs" } ] } # group Authentication 사용자 등록 및 인증을 위한 Api 입니다. myProject Api 는 사용자 인증을 위해 JWT Json Web Token 을 이용합니다. ## `token` 의 발급 및 사용 사용자 등록 및 로그인을 통해 생성된 `token` 은 myProject 서버에 새로운 리소스를 생성, 기존 리소스 수정 또는 삭제를 위한 Api 요청을 할 때 꼭 필요합니다. 전술한 Api 요청을 할 때 `token` 을 HTTP Authorization Header 에 붙여서 사용합니다. (e.g. `Authorization: Bearer token`) `token` 이 없으면 401 Unauthorized 응답을 받습니다. 한번 발급된 `token` 은 120 분 동안 유효하며, 유효한 기간 동안은 로그인 없이 Api 요청을 할 수 있습니다. 그래서, Api 클라이언트는 사용자 등록 및 로그인에서 받은 토큰을 로컬 저장소에 저장하고 있어야 합니다. ## `token` 의 만료 및 갱신 `token` 이 만료되면 서버는 401 Unauthorized 응답 코드와 함께 `token_expired` 메시지를 응답합니다. `token` 을 사용하지 않은지 총 2 주일이 지나지 않았다면, 기존 `token` 을 이용하여 새로운 토큰을 받을 수 있습니다. 2 주가 지나 더 이상 갱신이 불가한 상태가 되면, 서버로 부터 401 응답을 받게 되며 이때는 사용자에게 로그인 UI 를 표출하여 로그인을 하도록 유도하여 `token` 을 발급 받아야 합니다. ## User Registration [/auth/register] 새로운 사용자를 등록합니다. ### User Registration [POST] - request OK - headers Accept: application/json Content-type: application/json - body { "name": "New User", "email": "new_user@example.org", "password": "password", "password_confirmation": "password" } - response 201 (application/json) - Attributes - success (object) - code: `201` (number) - Http Status Equivalent Message Code - message: `Created` (string) - Message - meta (object) - token: `header.payload.signature` (string) - Json Web Token - request Invalid Data - headers Accept: application/json Content-type: application/json - body {"email": "invalid-email-address"} - response 422 (application/json) - Attributes - error (object) - code: `422` (number) - Http Status Equivalent Message Code - message (array) - `name 은(는) 필수 입력 항목 입니다.` - `email 은(는) 유효한 이메일 주소가 아닙니다.` - `password 은(는) 필수 입력 항목 입니다.` - request Duplicate User - headers Accept: application/json Content-type: application/json - body { "name": "John Doe", "email": "john@example.com", "password": "password", "password_confirmation": "password" } - response 422 (application/json) - Attributes - error (object) - code: `422` (number) - Http Status Equivalent Message Code - message (array) - `email 은(는) 이미 사용 중입니다.` ## User Login [/auth/login] 로그인을 하고 `token` 을 얻습니다. ### User Login [POST] - request OK - headers Accept: application/json Content-type: application/json - body { "email": "john@example.com", "password": "password" } - response 201 (application/json) - Attributes - success (object) - code: `201` (number) - Http Status Equivalent Message Code - message: `Created` (string) - Message - meta (object) - token: `header.payload.signature` (string) - Json Web Token - request Invalid Credential - headers Accept: application/json Content-type: application/json - body { "email": "john@example.com", "password": "wrong-password" } - response 401 (application/json) - Attributes - error (object) - code: `401` (number) - Http Status Equivalent Message Code - message: `invalid_credentials` (string) - Message - request Invalid Data - headers Accept: application/json Content-type: application/json - body { "email": "invalid-email-address", "password": "password" } email: invalid-email-address - response 422 (application/json) - Attributes - error (object) - code: `422` (number) - Http Status Equivalent Message Code - message (array) - `email 은(는) 유효한 이메일 주소가 아닙니다.` (string) ## Token Refresh [/auth/refresh] 만료된 `token` 을 갱신합니다. ### Token Refresh [POST] - request OK - headers Accept: application/json Authorization: Bearer header.payload.signagure - response 201 (application/json) - Attributes - success (object) - code: `201` (number) - Http Status Equivalent Message Code - message: `Created` (string) - Message - meta (object) - token: `header.payload.signature` (string) - Json Web Token # group Forum Forum Api 는 HTTP 요청을 통해 여러분의 Api 클라이언트가 서버에 저장된 게시글 `Article` 과 댓글 `Comment` 리소스를 이용할 수 있도록 해 줍니다. **`참고`** 댓글에 대한 생성, 수정, 삭제 Api 는 현재 구현되어 있지 않습니다. ## Articles Collection [/v1/articles] ### List All Articles [GET] `Article` Collection 을 요청합니다. 사용할 수 있는 쿼리스트링은 다음과 같습니다. #### Available Querystrings - `filter` - 필터 (선택, `no_comment`|`not_solved`) ```http GET /v1/articles?filter=no_comment ``` - `limit` - 응답 받을 게시물 수 (선택, `1`~`10`, 기본값 `5`) ```http GET /v1/articles?limit=3 ``` - `sort` - 정렬에 사용할 필드 (선택, `created`|`view_count`, 기본값 `created`) ```http GET /v1/articles?sort=view_count ``` - `order` - 정렬 방향 (선택, `asc`|`desc`, 기본값 `desc`) ```http GET /v1/articles?sort=view_count&order=asc ``` - `q` - 풀 텍스트 서치 키워드 (선택) ```http GET /v1/articles?q=hello%20api ``` - request OK - headers Accept: application/json - response 200 (application/json) - headers Etag: aK08i2L8jQRdjBcd - attributes - data (array[Article]) - meta (Pagination) - request Not Modified - headers Accept: application/json If-None-Match: etag - response 304 (application/json) ### Not-allowed Querystrings [GET /v1/articles?filter=not_existing_filter] - request Not-existing Querystrings - headers Accept: application/json - response 422 (application/json) - Attributes - error (object) - code: `422` (number) - Http Status Equivalent Message Code - message (object) - filter (array) - `filter 은(는) 유효하지 않습니다.` (string) ### Create New Article [POST] 새로운 `Article` 리소스를 생성합니다. - request OK - headers Accept: application/json Content-type: application/json Authorization: Bearer header.payload.signagure - body { "title": "some title", "content": "some content", "tags[]": 1, "tags[]": 2 } - response 201 (application/json) - attributes - success (object) - code: `201` (number) - Http Status Equivalent Message Code - message: `Created` (string) - Message - request Token Not Provided - headers Accept: application/json Content-type: application/json - response 400 (application/json) - attributes - error (object) - code: `400` (number) - Http Status Equivalent Message Code - message: `token_not_provided` (string) - Message - request Token Invalid - headers Accept: application/json Authorization: Bearer invalid.token.signature - response 400 (application/json) - attributes - error (object) - code: `400` (number) - Http Status Equivalent Message Code - message: `token_invalid` (string) - Message - request Token Expired - headers Authorization: Bearer expired.token.signagure Content-type: application/json - response 401 (application/json) - attributes - error (object) - code: `400` (number) - Http Status Equivalent Message Code - message: `token_expired` (string) - Message ## Articles Instance [/v1/articles/{id}] - parameters - id: `95280986` (required, number) ### Fetch Article Instance [GET] `id` 로 지정된 `Article` 리소스의 상세정보를 요청합니다. `Article Collection` 과 동일한 [Available Querystrings](#available_querystrings) 을 사용할 수 있습니다. - request OK - headers Accept: application/json - response 200 (application/json) - headers Etag: 31Ba8Uuo29Za4s2v - body { "id": 95280986, "title": "example title", "content_raw": "example content", "content_html": "

example content

", "created": "2015-12-19T10:37:42+0000", "view_count": 1, "link": { "rel": "self", "href": "/v1/articles/95280986" }, "comments": 1, "author": { "name": "John Doe", "email": "john@example.com", "avatar": "http://www.gravatar.com/d4c74594d841139328695756648b6bd6" }, "tags": [ "laravel", "eloquent" ], "attachments": 1 } - request Not Modified - headers Accept: application/json If-None-Match: 31Ba8Uuo29Za4s2v - response 304 (application/json) ### Fetch Not-existing Article Instance [GET /v1/articles/{id}] 없는 `id` 이면 404 Not Found 응답을 받습니다. - parameters - id: `00000000` (required, number) - request Not-existing Resource - headers Accept: application/json - response 404 (application/json) - Attributes - error (object) - code: `404` (number) - Http Status Equivalent Message Code - message: `No query results for model [App\\Article].` (string) - Message ### Update Article Instance [PUT] `id` 로 지정된 `Article` 의 내용을 수정합니다. 수정은 리소스의 소유자 또는 관리자에게만 허용되며, 이 조건이 충족되지 않을 경우에는 403 Forbidden 응답을 받습니다. - request OK - headers Accept: application/json Content-type: application/json Authorization: Bearer header.payload.signagure - body { "title": "updated title", "content": "updated content", "tags[]": "3" } - response 200 (application/json) - attributes - success (object) - code: `200` (number) - Http Status Equivalent Message Code - message: `Updated` (string) - Message - request Forbidden - headers Accept: application/json Content-type: application/json Authorization: Bearer not.owner.signagure - response 403 (application/json) - attributes - error (object) - code: `403` (number) - Http Status Equivalent Message Code - message: `Forbidden` (string) - Message ### Delete Article Instance [DELETE] `id` 로 지정된 `Article` 을 삭제합니다. 삭제는 리소스의 소유자 또는 관리자에게만 허용되며, 이 조건이 충족되지 않을 경우에는 403 Forbidden 응답을 받습니다. - request OK - headers Accept: application/json Authorization: Bearer header.payload.signagure - response 204 (application/json) - request Forbidden - headers Accept: application/json Authorization: Bearer not.owner.signagure - response 403 (application/json) - attributes - error (object) - code: `403` (number) - Http Status Equivalent Message Code - message: `Forbidden` (string) - Message # data structures ## Article (object) - id: `95280986` (number) - 고유 키 - title: `example title` (string) - 제목 - content_raw: `example content` (string) - 본문 (Markdown 형식) - content_html: `

example content

` (string) - 본문 (HTML 컴파일) - created: `2015-12-19T10:37:42+0000` (string) - 생성일 - view_count: 1 (number) - 조회 수 - link (object) - rel: `self` - href: `/v1/articles/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint - comments: `1` (number) - 댓글 개수 - author (object) - name: `John Doe` (string) - 작성자 이름 - email: `john@example.com` (string) - 작성자 이메일 - avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 작성자 아바타 URL - tags (array) - laravel - eloquent - attachments: `1` (number) - 첨부파일 개수 ## User (object) - id: `95280986` (number) - 고유 키 - name: `John Doe` (string) - 사용자 이름 - email: `john@example.com` (string) - 사용자 이메일 - avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 사용자 아바타 URL - signup: `2016-01-12T06:18:10+0000` - 가입일 - link (object) - rel: `self` - href: `/users/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint - articles: `1` (number) - 포럼 게시글 개수 - comments: `1` (number) - 작성한 댓글 개수 ## Comment (object) - id: `95280986` (number) - 고유 키 - content_raw: `example content` (string) - 본문 (Markdown 형식) - content_html: `

example content

` (string) - 본문 (HTML 컴파일) - created: `2015-12-19T10:37:42+0000` (string) - 생성일 - vote (object) - up: `1` (number) - 좋아요 투표 수 - down: `1` (number) - 싫어요 투표 수 - link (object) - rel: `self` - href: `/v1/comments/95280986` (string) - 상세 내용 요청을 위한 Api Endpoint - author (object) - name: `John Doe` (string) - 작성자 이름 - email: `john@example.com` (string) - 작성자 이메일 - avatar: `http://www.gravatar.com/d4c74594d841139328695756648b6bd6` (string) - 작성자 아바타 URL ## Tag (object) - id: `95280986` (number) - 고유 키 - slug: `laravel` (string) - Slug - created: `2015-12-19T10:37:42+0000` (string) - 생성일 - link (object) - rel: `self` - href: `/v1/tags/laravel/articles` (string) - 상세 내용 요청을 위한 Api Endpoint - articles: 1 (number) - 태그에 해당하는 게시글 개수 ## Attachment (object) - id: `95280986` (number) - 고유 키 - name: `kEvzc4qBPwEze1mi.jpg` - created: `2015-12-19T10:37:42+0000` (string) - 생성일 - link (object) - rel: `self` - href: `http://myproject.dev:8000/attachments/kEvzc4qBPwEze1mi.jpg` (string) - 첨부파일 다운로드 URL ## Pagination - pagination (object) - total: `20` (number) - 전체 게시글 개수 - count: `5` (number) - 현재 응답의 게시글 개수 - per_page: `5` (number) - 요청당 응답할 게시글 개수 - current_page: `2` (number) - 현재 페이지 번호 - total_pages: `4` (number) - 총 페이지 수 - links (object) - previous: `/v1/articles?page=1` (string) - 이전 페이지 URL - next: `/v1/articles?page=3` (string) - 다음 페이지 URL ================================================ FILE: app/Article.php ================================================ belongsTo(User::class, 'author_id'); } public function tags() { return $this->belongsToMany(Tag::class)->withTimestamps(); } public function comments() { return $this->morphMany(Comment::class, 'commentable'); } public function solution() { return $this->hasOne(Comment::class, 'id', 'solution_id'); } public function attachments() { return $this->hasMany(Attachment::class); } /* Query Scope */ public function scopeNoComment($query) { return $query->has('comments', '<', 1); } public function scopeNotSolved($query) { return $query->whereNull('solution_id'); } /* Helpers */ public function isNotice() { return $this->pin ? true : false; } public function etag($cacheKey = null) { $etag = $this->getTable() . $this->getKey(); if ($this->usesTimestamps()) { $etag .= $this->updated_at->timestamp; } return md5($etag.$cacheKey); } } ================================================ FILE: app/Attachment.php ================================================ belongsTo(Article::class); } } ================================================ FILE: app/AuthorTrait.php ================================================ author->id == auth()->user()->id; } } ================================================ FILE: app/Comment.php ================================================ sum('up'); } public function getDownCountAttribute() { return (int) static::votes()->sum('down'); } /* Relationships */ public function author() { return $this->belongsTo(User::class, 'author_id'); } public function commentable() { return $this->morphTo(); } public function replies() { return $this->hasMany(Comment::class, 'parent_id')->latest(); } public function parent() { return $this->belongsTo(Comment::class, 'parent_id', 'id'); } public function votes() { return $this->hasMany(Vote::class); } } ================================================ FILE: app/Console/Commands/BackupDb.php ================================================ %s -u%s -p%s', $this->option('db'), storage_path("backup/{$this->option('db')}.sql"), $this->argument('user'), $this->argument('pass') ); system($command); $now = \Carbon\Carbon::now()->toDateTimeString(); $result = "{$this->getName()} command done at {$now}"; \Log::info($result); return $this->info($result); } } ================================================ FILE: app/Console/Commands/ClearLog.php ================================================ ' . $path); $now = \Carbon\Carbon::now()->toDateTimeString(); $result = "{$this->getName()} command done at {$now}"; \Log::info($result); return $this->info($result); } } ================================================ FILE: app/Console/Commands/Inspire.php ================================================ comment(PHP_EOL.Inspiring::quote().PHP_EOL); } } ================================================ FILE: app/Console/Commands/PruneRelease.php ================================================ argument('path'); $dirs = collect(File::directories($path))->sortByDesc(function($dir) { return File::lastModified($dir); }); $dirs->values()->splice($this->option($keep))->map(function($dir) { File::deleteDirectory($dir); $this->info(sprintf('%s removed.', $dir)); }); $now = \Carbon\Carbon::now()->toDateTimeString(); $result = sprintf( '%s command done at %s. %d %s removed', $this->getName(), $now, $dirs->count(), str_plural('release', $dirs->count()) ); \Log::info($result . ': ' . $dirs->toJson()); return $this->warn($result); } } ================================================ FILE: app/Console/Commands/UpdateLessonsTable.php ================================================ name); $lesson->content = \File::get($path); $lesson->save(); $lesson->touch(); $this->info(sprintf('Success updating %d: %s', $lesson->id, $lesson->name)); } return $this->warn('Finished.'); } } ================================================ FILE: app/Console/Kernel.php ================================================ command('inspire')->hourly(); $schedule->command('my:clear-log')->monthly(); $schedule->command(sprintf( 'my:backup-db %s %s', env('DB_USERNAME'), env('DB_PASSWORD') ))->dailyAt('03:00'); } } ================================================ FILE: app/Events/ArticleConsumed.php ================================================ article = $article; } } ================================================ FILE: app/Events/Event.php ================================================ cacheTags = $cacheTags; } } ================================================ FILE: app/Exceptions/Handler.php ================================================ shouldReport($e) and app()->environment('production')) { app(\App\Reporters\ErrorReport::class, [$e])->send(); } return parent::report($e); } /** * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request * @param \Exception $e * @return \Illuminate\Http\Response */ public function render($request, Exception $e) { if (app()->environment('production')) { $title = 'Error'; $description = 'Unknown error occurred :('; $statusCode = 400; if ($e instanceof ModelNotFoundException or $e instanceof NotFoundHttpException) { $title = trans('errors.not_found'); $description = trans('errors.not_found_description'); $statusCode = 404; } return response(view('errors.notice', [ 'title' => $title, 'description' => $description ]), $e->getCode() ?: $statusCode); } if (is_api_request()) { $statusCode = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : $e->getCode(); if ($e instanceof TokenExpiredException) { $message = 'token_expired'; } elseif ($e instanceof TokenInvalidException) { $message = 'token_invalid'; } elseif ($e instanceof JWTException) { $message = $e->getMessage() ?: 'could_not_create_token'; } elseif ($e instanceof NotFoundHttpException or $e instanceof ModelNotFoundException) { $statusCode = 404; $message = $e->getMessage() ?: 'not_found'; } elseif ($e instanceof MethodNotAllowedHttpException) { $message = $e->getMessage() ?: 'not_allowed'; } elseif ($e instanceof HttpResponseException){ return $e->getResponse(); } elseif ($e instanceof Exception){ $message = $e->getMessage() ?: 'Whoops~ Tell me what you did :('; } return json()->setStatusCode($statusCode ?: 400)->error($message); } return parent::render($request, $e); } } ================================================ FILE: app/Http/Controllers/Api/PasswordsController.php ================================================ middleware = []; $this->middleware('throttle.api:10,1'); parent::__construct(); } /** * Make an error response. * * @param $message * @param int $statusCode * @return \Illuminate\Http\JsonResponse */ protected function respondError($message, $statusCode = 400) { return json()->setStatusCode($statusCode)->error('not_found'); } /** * Make a success response. * * @param $message * @return \Illuminate\Http\RedirectResponse */ protected function respondSuccess($message) { return json()->success(); } } ================================================ FILE: app/Http/Controllers/Api/SessionsController.php ================================================ middleware = []; $this->middleware('throttle.api:10,1'); $this->middleware('jwt.refresh', ['only' => 'refresh']); parent::__construct(); } /** * Blank method for token refresh. * * @return bool */ public function refresh() { return true; } /** * Make validation error response. * * @param \Illuminate\Contracts\Validation\Validator $validator * @return \Illuminate\Http\JsonResponse */ protected function respondValidationError(Validator $validator) { return json()->unprocessableError($validator->errors()->all()); } /** * Make login failed response. * * @return \Illuminate\Http\JsonResponse */ protected function respondLoginFailed() { return json()->unauthorizedError('invalid_credentials'); } /** * Make a success response. * * @param string $return * @param string $token * @return \Illuminate\Http\JsonResponse */ protected function respondCreated($return = '', $token = '') { return json()->setMeta(['token' => $token])->created(); } } ================================================ FILE: app/Http/Controllers/Api/UsersController.php ================================================ middleware = []; $this->middleware('throttle.api:10,1'); parent::__construct(); } /** * Display the specified resource. * * @param int $id * @return \Illuminate\Http\JsonResponse */ public function show($id) { return json()->withItem( \App\User::with('articles', 'comments')->findOrFail($id), new \App\Transformers\UserTransformer ); } /** * Make validation error response. * * @param \Illuminate\Contracts\Validation\Validator $validator * @return \Illuminate\Http\JsonResponse */ protected function respondValidationError(Validator $validator) { return json()->unprocessableError($validator->errors()->all()); } /** * Make a success response. * * @param \App\User $user * @return \Illuminate\Http\JsonResponse */ protected function respondCreated(User $user) { return json()->setMeta(['token' => \JWTAuth::fromUser($user)])->created(); } } ================================================ FILE: app/Http/Controllers/Api/V1/ArticlesController.php ================================================ middleware('jwt.auth', ['except' => ['index', 'show']]); $this->middleware('throttle.api:60,1'); $this->middleware('obfuscate:article'); parent::__construct(); } /** * Respond Article collection in JSON. * * @param \Illuminate\Pagination\LengthAwarePaginator $articles * @param string|null $cacheKey * @return \Illuminate\Http\JsonResponse */ protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null) { $reqEtag = request()->getETags(); $genEtag = $this->etags($articles, $cacheKey); if (config('project.cache') === true and isset($reqEtag[0]) and $reqEtag[0] === $genEtag) { return $this->respondNotModified(); } return json()->setHeaders(['Etag' => $genEtag])->withPagination( $articles, new ArticleTransformer ); } /** * Respond 201 in JSON. * * @param \App\Article $article * @return \Illuminate\Http\JsonResponse */ protected function respondCreated(Article $article) { return json()->created(); } /** * Respond single Article item in JSON. * * @param \App\Article $article * @param \Illuminate\Database\Eloquent\Collection|null $commentsCollection * @param string|null $cacheKey * @return \Illuminate\Http\JsonResponse */ protected function respondItem(Article $article, Collection $commentsCollection = null, $cacheKey = null) { $reqEtag = request()->getETags(); $genEtag = $article->etag($cacheKey); if (config('project.cache') === true and isset($reqEtag[0]) and $reqEtag[0] === $genEtag) { return $this->respondNotModified(); } return json()->setHeaders(['Etag' => $genEtag])->withItem($article, new ArticleTransformer); } /** * Respond Updated in JSON. * * @param \App\Article $article * @return \Illuminate\Http\JsonResponse */ protected function respondUpdated(Article $article) { return json()->success('Updated'); } /** * Respond 204 Deleted. * * @param \App\Article $article * @return \Illuminate\Http\JsonResponse */ protected function respondDeleted(Article $article) { return json()->noContent(); } /** * Respond Not Modified; * * @return \Illuminate\Contracts\Http\Response */ protected function respondNotModified() { return json()->notModified(); } } ================================================ FILE: app/Http/Controllers/Api/V1/CommentsController.php ================================================ middleware('jwt.auth'); parent::__construct(); } /** * Display a listing of the resource. */ public function index() { return json()->withPagination( \App\Comment::paginate(5), new CommentTransformer ); } /** * Display the specified resource. * * @param int $id * @return \Illuminate\Http\JsonResponse */ public function show($id) { $article = \App\Comment::findOrFail($id); return json()->withItem( $article, new CommentTransformer ); } } ================================================ FILE: app/Http/Controllers/Api/V1/WelcomeController.php ================================================ 'myProject Api', 'message' => 'Welcome to myProject Api. This is a base endpoint of version 1.', 'version' => 'v1', 'links' => [ [ 'rel' => 'self', 'href' => route(\Route::currentRouteName()) ], [ 'rel' => 'api.users.store', 'href' => route('api.users.store') ], [ 'rel' => 'api.sessions.store', 'href' => route('api.sessions.store') ], [ 'rel' => 'api.v1.docs', 'href' => 'http://docs.forumv1.apiary.io/' ], ], ]); } } ================================================ FILE: app/Http/Controllers/Api/WelcomeController.php ================================================ 'myProject Api', 'message' => 'Welcome to myProject Api. This is a base endpoint.', 'version' => 'n/a', 'links' => [ [ 'rel' => 'self', 'href' => route(\Route::currentRouteName()) ], [ 'rel' => 'api.users.store', 'href' => route('api.users.store') ], [ 'rel' => 'api.sessions.store', 'href' => route('api.sessions.store') ], [ 'rel' => 'api.v1.index', 'href' => route('api.v1.index') ], [ 'rel' => 'api.v1.docs', 'href' => 'http://docs.forumv1.apiary.io/' ], ], ]); } } ================================================ FILE: app/Http/Controllers/ArticlesController.php ================================================ middleware('author:article', ['only' => ['update', 'destroy', 'pickBest']]); if (! is_api_request()) { $this->middleware('auth', ['except' => ['index', 'show']]); $allTags = \Cache::remember('tags', 30, function() { return Tag::with('articles')->get(); }); view()->share('allTags', $allTags); } parent::__construct(); } public function cacheKeys() { return 'articles'; } /** * Display a listing of the resource. * * @param \App\Http\Requests\FilterArticlesRequest $request * @param string|null $slug * @return \Illuminate\Http\Response */ public function index(FilterArticlesRequest $request, $slug = null) { $query = $slug ? Tag::whereSlug($slug)->firstOrFail()->articles() : new Article; $cacheKey = cache_key('articles.index'); $query = $this->filter($query->orderBy('pin', 'desc')); $args = $request->input(config('project.params.limit'), 5); $articles = $this->cache($cacheKey, 5, $query, 'paginate', $args); return $this->respondCollection($articles, $cacheKey); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\Response */ public function create() { $article = new Article; return view('articles.create', compact('article')); } /** * Store a newly created resource in storage. * * @param \App\Http\Requests\ArticlesRequest $request * @return \Illuminate\Http\Response */ public function store(ArticlesRequest $request) { $payload = array_merge($request->except('_token'), [ 'notification' => $request->has('notification'), ]); $article = $request->user()->articles()->create($payload); $article->tags()->sync($request->input('tags')); if ($request->has('attachments')) { $attachments = \App\Attachment::whereIn('id', $request->input('attachments'))->get(); $attachments->each(function ($attachment) use ($article) { $attachment->article()->associate($article); $attachment->save(); }); } event(new ModelChanged(['articles', 'tags'])); return $this->respondCreated($article); } /** * Display the specified resource. * * @param int $id * @return \Illuminate\Http\Response */ public function show($id) { $cacheKey = cache_key("articles.show.{$id}"); $secondKey = cache_key("articles.show.{$id}.comments"); $query = Article::with('comments', 'tags', 'attachments', 'solution')->findOrFail($id); $article = $this->cache($cacheKey, 5, $query, 'findOrFail', $id); $secondQuery = $article->comments()->with('replies')->withTrashed()->whereNull('parent_id')->latest(); $commentsCollection = $this->cache($secondKey, 5, $secondQuery, 'get'); if (! is_api_request()) { event(new ArticleConsumed($article)); } return $this->respondItem($article, $commentsCollection, $cacheKey.$secondKey); } /** * Show the form for editing the specified resource. * * @param int $id * @return \Illuminate\Http\Response */ public function edit($id) { $article = Article::findOrFail($id); return view('articles.edit', compact('article')); } /** * Update the specified resource in storage. * * @param \App\Http\Requests\ArticlesRequest $request * @param int $id * @return \Illuminate\Http\Response */ public function update(ArticlesRequest $request, $id) { $payload = array_merge($request->except('_token'), [ 'notification' => $request->has('notification'), ]); $article = Article::findOrFail($id); $article->update($payload); if ($request->has('tags')) { $article->tags()->sync($request->input('tags')); } event(new ModelChanged(['articles', 'tags'])); return $this->respondUpdated($article); } public function pickBest(Request $request, $id) { $this->validate($request, [ 'solution_id' => 'required|numeric|exists:comments,id', ]); Article::findOrFail($id)->update([ 'solution_id' => $request->input('solution_id'), ]); return json()->noContent(); } /** * Remove the specified resource from storage. * * @param \Illuminate\Http\Request $request * @param int $id * @return \Illuminate\Http\Response * @throws \Exception */ public function destroy(Request $request, $id) { $article = Article::with('attachments', 'comments')->findOrFail($id); foreach ($article->attachments as $attachment) { \File::delete(attachment_path($attachment->name)); } $article->attachments()->delete(); $article->comments->each(function ($comment) use ($request) { app(\App\Http\Controllers\CommentsController::class)->destroy($request, $comment->id); }); $article->delete(); event(new ModelChanged('articles')); if ($request->ajax()) { return response()->json('', 204); } return $this->respondDeleted($article); } /** * Respond Article Collection. * * @param \Illuminate\Pagination\LengthAwarePaginator $articles * @param string|null $cacheKey * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ protected function respondCollection(LengthAwarePaginator $articles, $cacheKey = null) { return view('articles.index', compact('articles')); } /** * Respond Created. * * @param \App\Article $article * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function respondCreated(Article $article) { flash()->success(trans('common.created')); return redirect(route('articles.index')); } /** * Respond single Article item with a corresponding Comment collection. * * @param \App\Article $article * @param \Illuminate\Database\Eloquent\Collection|null $commentsCollection * @param string|null $cacheKey * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ protected function respondItem(Article $article, Collection $commentsCollection = null, $cacheKey = null) { return view('articles.show', [ 'article' => $article, 'comments' => $commentsCollection, 'commentableType' => Article::class, 'commentableId' => $article->id, ]); } /** * Respond Updated. * * @param \App\Article $article * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function respondUpdated(Article $article) { flash()->success(trans('common.updated')); return redirect(route('articles.show', $article->id)); } /** * Respond Deleted. * * @param \App\Article $article * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function respondDeleted(Article $article) { flash()->success(trans('common.deleted')); return redirect(route('articles.index')); } /** * Respond Not Modified. * * @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response */ protected function respondNotModified() { return response('', 304); } } ================================================ FILE: app/Http/Controllers/AttachmentsController.php ================================================ hasFile('file')) { return response()->json('File not passed !', 422); } // Save file $file = $request->file('file'); $name = time() . '_' . str_replace(' ', '_', $file->getClientOriginalName()); $file->move(attachment_path(), $name); $articleId = $request->input('articleId'); // Persist Attachment model $attachment = $articleId ? \App\Article::findOrFail($articleId)->attachments()->create(['name' => $name]) : \App\Attachment::create(['name' => $name]); event(new ModelChanged('attachments')); return response()->json([ 'id' => $attachment->id, 'name' => $name, 'type' => $file->getClientMimeType(), 'url' => sprintf("/attachments/%s", $name), ]); } /** * Remove the specified resource from storage. * * @param \Illuminate\Http\Request $request * @param int $id * @return \Illuminate\Http\Response */ public function destroy(Request $request, $id) { $attachment = \App\Attachment::findOrFail($id); $path = attachment_path($attachment->name); if (\File::exists($path)) { \File::delete($path); } $attachment->delete(); event(new ModelChanged('attachments')); if ($request->ajax()) { return response()->json('', 204); } flash()->success(trans('common.deleted')); return back(); } } ================================================ FILE: app/Http/Controllers/Cacheable.php ================================================ middleware('auth'); $this->middleware('author:comment', ['except' => ['store', 'vote']]); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */ public function store(Request $request) { $this->validate($request, [ 'commentable_type' => 'required|in:App\Article,App\Lesson', 'commentable_id' => 'required|numeric', 'parent_id' => 'numeric|exists:comments,id', 'content' => 'required', ]); $parentModel = "\\" . $request->input('commentable_type'); $comment = $parentModel::find($request->input('commentable_id')) ->comments()->create([ 'author_id' => \Auth::user()->id, 'parent_id' => $request->input('parent_id', null), 'content' => $request->input('content') ]); event('comments.created', [$comment]); event(new ModelChanged('articles', 'comments')); flash()->success(trans('forum.comment_add')); return back(); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * @return \Illuminate\Http\Response */ public function update(Request $request, $id) { $this->validate($request, ['content' => 'required']); $comment = Comment::findOrFail($id); $comment->update($request->only('content')); event('comments.updated', [$comment]); event(new ModelChanged('articles', 'comments')); flash()->success(trans('forum.comment_edit')); return back(); } /** * Vote up or down for the given comment. * * @param \Illuminate\Http\Request $request * @param $id * @return \Illuminate\Http\JsonResponse */ public function vote(Request $request, $id) { $this->validate($request, [ 'vote' => 'required|in:up,down', ]); if(Vote::whereCommentId($id)->whereUserId($request->user()->id)->exists()) { return response()->json(['errors' => 'Already voted!'], 409); } $comment = Comment::findOrFail($id); $up = $request->input('vote') == 'up' ? true : false; $comment->votes()->create([ 'user_id' => $request->user()->id, 'up' => $up ? 1 : null, 'down' => $up ? null : 1, 'voted_at' => \Carbon\Carbon::now()->toDateTimeString(), ]); return response()->json([ 'voted' => $request->input('vote'), 'value' => $comment->votes()->sum($request->input('vote')) ]); } /** * Remove the specified resource from storage. * * @param \Illuminate\Http\Request $request * @param int $id * @return \Illuminate\Http\Response */ public function destroy(Request $request, $id) { $comment = Comment::with('replies')->find($id); // Do not recursively destroy children comments. // Because 1. Soft delete feature was adopted, // and 2. it's not just pleasant for authors of children comments to being deleted by the parent author. if ($comment->replies->count() > 0) { $comment->delete(); } else { if ($comment->votes->count()) { $this->deleteVote($comment->votes); } $comment->forceDelete(); } // $this->recursiveDestroy($comment); event(new ModelChanged('articles', 'comments')); if ($request->ajax()) { return response()->json('', 204); } flash()->success(trans('common.deleted')); return back(); } /** * Delete given votes collection. * * @param $votes */ protected function deleteVote($votes) { foreach($votes as $vote) { $vote->delete(); } } /** * Delete comment recursively * * @param \App\Comment $comment * @return bool|null */ public function recursiveDestroy(Comment $comment) { if ($comment->replies->count()) { $comment->replies->each(function($reply) { if ($reply->replies->count()) { $this->recursiveDestroy($reply); } else { $reply->delete(); } }); } return $comment->delete(); } } ================================================ FILE: app/Http/Controllers/Controller.php ================================================ setSharedVariables(); } $this->cache = app('cache'); if ((new \ReflectionClass($this))->implementsInterface(\App\Http\Controllers\Cacheable::class) and taggable()) { $this->cache = app('cache')->tags($this->cacheKeys()); } } /** * Share common view variables */ protected function setSharedVariables() { view()->share('currentLocale', app()->getLocale()); view()->share('currentUser', auth()->user()); view()->share('currentRouteName', \Route::currentRouteName()); view()->share('currentUrl', current_url()); view()->share('isLandingPage', in_array(\Route::currentRouteName(), ['home', 'index'])); } /** * Do the filter, search, and sorting job * * @param $query * @return mixed */ protected function filter($query) { $params = config('project.params'); if ($filter = request()->input($params['filter'])) { $query = $query->{camel_case($filter)}(); } if ($keyword = request()->input($params['search'])) { $raw = 'MATCH(title,content) AGAINST(? IN BOOLEAN MODE)'; $query = $query->whereRaw($raw, [$keyword]); } $sort = request()->input($params['sort'], 'created_at'); $direction = request()->input($params['order'], 'desc'); if ($sort == 'created') { // We transformed field name of 'created_at' to 'created'. // Applicable only to api request. But this code laid here // to suppress QueryException of not existing column in web request. $sort = 'created_at'; } return $query->orderBy($sort, $direction); } /** * Create etag against collection of resources. * * @param \Illuminate\Database\Eloquent\Collection|\Illuminate\Contracts\Pagination\Paginator| $collection * @param string|null $cacheKey * @return string */ protected function etags($collection, $cacheKey = null) { $etag = ''; foreach($collection as $instance) { $etag .= $instance->etag(); } return md5($etag.$cacheKey); } /** * Execute caching against database query. * * @see config/project.php's cache section. * * @param string $key * @param int $minutes * @param \App\Model|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder * |\Illuminate\Database\Eloquent\Relations\Relation $query * @param string $method * @param mixed ...$args * @return mixed */ protected function cache($key, $minutes, $query, $method, ...$args) { $args = (! empty($args)) ? implode(',', $args) : null; if (config('project.cache') === false) { return $query->{$method}($args); } return $this->cache->remember($key, $minutes, function() use($query, $method, $args){ return $query->{$method}($args); }); } } ================================================ FILE: app/Http/Controllers/LessonsController.php ================================================ repo = $repo; parent::__construct(); } /** * Show document page in response to the given $file. * * @param string $file * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function show($file = '01-welcome.md') { $lesson = $this->repo->find($file); $commentsCollection = $lesson->comments()->with('replies') ->withTrashed()->whereNull('parent_id')->latest()->get(); return view('lessons.show', [ 'index' => $this->repo->index(), 'lesson' => $lesson, 'prev' => $this->repo->prev($file), 'next' => $this->repo->next($file), 'comments' => $commentsCollection, 'commentableType' => $this->repo->model(), 'commentableId' => $lesson->id, ]); } /** * Make image response. * * @param $file * @return \Illuminate\Http\Response */ public function image($file) { $image = $this->repo->image($file); $reqEtag = Request::getEtags(); $genEtag = $this->repo->etag($file); if (isset($reqEtag[0])) { if ($reqEtag[0] === $genEtag) { return response('', 304); } } return response($image->encode('png'), 200, [ 'Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=0', 'Etag' => $genEtag, ]); } /** * Leave off unnecessary string from the given path. * * @param $path * @return mixed */ protected function sanitizePath($path) { return starts_with($path, ['/lessons/', 'lessons/']) ? pathinfo($path, PATHINFO_BASENAME) : $path; } } ================================================ FILE: app/Http/Controllers/PasswordsController.php ================================================ middleware('guest'); parent::__construct(); } /** * Display the form to request a password reset link. * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function getRemind() { return view('passwords.remind'); } /** * Send a reset link to the given user. * * @param \Illuminate\Http\Request $request * @return $this|\Illuminate\Http\RedirectResponse */ public function postRemind(Request $request) { $this->validate($request, ['email' => 'required|email']); if (User::whereEmail($request->input('email'))->noPassword()->first()) { // Notify the user if he/she is a social login user. $message = sprintf("%s %s", trans('auth.social_olny'), trans('auth.no_password')); return $this->respondError($message, 400); } $response = Password::sendResetLink($request->only('email'), function ($m) { $m->subject(trans('auth.email_password_reset_title')); }); switch ($response) { case Password::RESET_LINK_SENT: return $this->respondSuccess(trans($response)); case Password::INVALID_USER: return $this->respondError(trans($response), 404); } } /** * Display the password reset view for the given token. * * @param null $token * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function getReset($token = null) { if (is_null($token) or strlen($token) != 64) { throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException; } return view('passwords.reset', compact('token')); } /** * Reset the given user's password. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ public function postReset(Request $request) { $this->validate($request, [ 'token' => 'required', 'email' => 'required|email', 'password' => 'required|confirmed', ]); $credentials = $request->only( 'email', 'password', 'password_confirmation', 'token' ); $response = Password::reset($credentials, function ($user, $password) { $user->password = bcrypt($password); $user->save(); \Auth::login($user); }); switch ($response) { case Password::PASSWORD_RESET: flash(sprintf( "%s %s", trans($response), trans('auth.welcome', ['name' => \Auth::user()->name]) )); return redirect(route('home')); default: flash()->error(trans($response)); return back() ->withInput($request->only('email')); } } /** * Make an error response. * * @param $message * @param int $statusCode * @return \Illuminate\Http\RedirectResponse */ protected function respondError($message, $statusCode = 400) { flash()->error($message); return back()->withInput(); } /** * Make a success response. * * @param $message * @return \Illuminate\Http\RedirectResponse */ protected function respondSuccess($message) { flash($message); return back(); } } ================================================ FILE: app/Http/Controllers/SessionsController.php ================================================ middleware('guest', ['except' => 'destroy']); parent::__construct(); } /** * Show the application login form. * * @return \Illuminate\Http\Response */ public function create() { return view('sessions.create'); } /** * Handle login request to the application. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ public function store(Request $request) { $validator = \Validator::make($request->all(), [ 'email' => 'required|email', 'password' => 'required|min:6', ]); if ($validator->fails()) { return $this->respondValidationError($validator); } $token = is_api_request() ? \JWTAuth::attempt($request->only('email', 'password')) : Auth::attempt($request->only('email', 'password'), $request->has('remember')); if (! $token) { return $this->respondLoginFailed(); } event('users.login', [Auth::user()]); return $this->respondCreated($request->input('return'), $token); } /** * Log the user out of the application. * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ public function destroy() { Auth::logout(); flash(trans('auth.goodbye')); return redirect(route('index')); } /** * Make validation error response. * * @param \Illuminate\Contracts\Validation\Validator $validator * @return \Illuminate\Http\RedirectResponse */ protected function respondValidationError(Validator $validator) { return back()->withInput()->withErrors($validator); } /** * Make login failed response. * * @return \Illuminate\Http\RedirectResponse */ protected function respondLoginFailed() { flash()->error(trans('auth.failed')); return back()->withInput(); } /** * Make a success response. * * @param string $return * @param string $token * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function respondCreated($return = '', $token = '') { flash(trans('auth.welcome', ['name' => Auth::user()->name])); return ($return) ? redirect(urldecode($return)) : redirect()->intended(); } } ================================================ FILE: app/Http/Controllers/SocialController.php ================================================ middleware('guest', ['only' => 'execute']); $this->socialite = $socialite; parent::__construct(); } /** * Handle social login process. * * @param \Illuminate\Http\Request $request * @param string $provider * @return \App\Http\Controllers\Response */ public function execute(Request $request, $provider) { if (! $request->has('code')) { return $this->redirectToProvider($provider); } return $this->handleProviderCallback($provider); } /** * Redirect the user to the Social Login Provider's authentication page. * * @param string $provider * @return \Symfony\Component\HttpFoundation\RedirectResponse */ protected function redirectToProvider($provider) { return $this->socialite->driver($provider)->redirect(); } /** * Obtain the user information from the Social Login Provider. * * @param string $provider * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function handleProviderCallback($provider) { $user = $this->socialite->driver($provider)->user(); $user = (User::whereEmail($user->getEmail())->first()) ?: User::create([ 'name' => $user->getName() ?: 'unknown', 'email' => $user->getEmail(), ]); \Auth::login($user, true); event('users.login', [$user]); flash(trans('auth.welcome', ['name' => $user->name])); return redirect(route('home')); } } ================================================ FILE: app/Http/Controllers/UsersController.php ================================================ middleware('guest'); parent::__construct(); } /** * Show the application registration form. * * @return \Illuminate\Http\Response */ public function create() { return view('users.create'); } /** * Handle a registration request for the application. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */ public function store(Request $request) { if ($user = User::whereEmail($request->input('email'))->noPassword()->first()) { // Filter through the User model to find whether there is a social account // that has the same email address with the current request return $this->syncAccountInfo($request, $user); } return $this->createAccount($request); } /** * A user logged into the application with social account first, * and then, when s/he tries to register an application's native account, * update his/her name and password as given * * @param \Illuminate\Http\Request $request * @param \App\User $user * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function syncAccountInfo(Request $request, User $user) { $validator = \Validator::make($request->except('_token'), [ 'name' => 'required|max:255', 'email' => 'required|email|max:255', 'password' => 'required|confirmed|min:6', ]); if ($validator->fails()) { return $this->respondValidationError($validator); } $user->update([ 'name' => $request->input('name'), 'password' => bcrypt($request->input('password')), ]); $this->addMemberRole($user); return $this->respondCreated($user); } /** * A user tries to register a native account. * S/he haven't logged in to the application with a social account before. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function createAccount(Request $request) { $validator = \Validator::make($request->except('_token'), [ 'name' => 'required|max:255', 'email' => 'required|email|max:255|unique:users', 'password' => 'required|confirmed|min:6', ]); if ($validator->fails()) { return $this->respondValidationError($validator); } $user = User::create([ 'name' => $request->input('name'), 'email' => $request->input('email'), 'password' => bcrypt($request->input('password')), ]); $this->addMemberRole($user); return $this->respondCreated($user); } /** * Attach Role to the user * * @param \App\User $user * @return array */ protected function addMemberRole(User $user) { // 1 is admin, 2 is member return $user->roles()->sync([2]); } /** * Make validation error response. * * @param $validator * @return \Illuminate\Http\RedirectResponse */ protected function respondValidationError(Validator $validator) { return back()->withInput()->withErrors($validator); } /** * Make a success response. * * @param \App\User $user * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ protected function respondCreated(User $user) { \Auth::login($user); flash(trans('auth.welcome', ['name' => $user->name])); return redirect(route('home')); } } ================================================ FILE: app/Http/Controllers/WelcomeController.php ================================================ middleware('auth', ['only' => ['home']]); parent::__construct(); } /** * Get the index page * * @return \Illuminate\Contracts\View\Factory */ public function index() { return view('home'); } /** * Get the home page * * @return \Illuminate\Contracts\View\Factory */ public function home() { return view('home'); } /** * Set locale preference of a user * * @return \Illuminate\Http\RedirectResponse */ public function locale() { $cookie = cookie()->forever('locale__myProject', request('locale')); cookie()->queue($cookie); return ($return = request('return')) ? redirect(urldecode($return))->withCookie($cookie) : redirect(\Auth::check() ? route('home') : route('index'))->withCookie($cookie); } } ================================================ FILE: app/Http/Kernel.php ================================================ [ // \App\Http\Middleware\EncryptCookies::class, // \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\VerifyCsrfToken::class, // ], // // 'api' => [ // 'throttle:60,1', // ], ]; /** * The application's route middleware. * * @var array */ protected $routeMiddleware = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle.api'=> \App\Http\Middleware\ThrottleApiRequests::class, 'role' => \Bican\Roles\Middleware\VerifyRole::class, 'author' => \App\Http\Middleware\AuthorOnly::class, 'jwt.auth' => \App\Http\Middleware\GetUserFromToken::class, 'jwt.refresh' => \App\Http\Middleware\RefreshToken::class, 'obfuscate' => \App\Http\Middleware\ObfuscateId::class, ]; } ================================================ FILE: app/Http/Middleware/Authenticate.php ================================================ auth = $auth; } /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { if ($this->auth->guest()) { if ($request->ajax()) { return response('Unauthorized.', 401); } else { return redirect()->guest( route('sessions.create', ['return' => $request->fullUrl()]) ); } } return $next($request); } } ================================================ FILE: app/Http/Middleware/AuthorOnly.php ================================================ user(); $model = '\\App\\' . ucfirst($param); $modelId = $request->route($param ? str_plural($param) : 'id'); if (! $model::whereId($modelId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) { if (is_api_request()) { return json()->forbiddenError(); } flash()->error(trans('errors.forbidden') . ' : ' . trans('errors.forbidden_description')); return back(); } return $next($request); } } ================================================ FILE: app/Http/Middleware/EncryptCookies.php ================================================ auth->setRequest($request)->getToken()) { throw new JWTException('token_not_provided', 400); } if (! $user = $this->auth->authenticate($token)) { throw new JWTException('user_not_found', 404); } $this->events->fire('tymon.jwt.valid', $user); return $next($request); } } ================================================ FILE: app/Http/Middleware/ObfuscateId.php ================================================ route()->getParameter($routeParamName)) { $request->route()->setParameter($routeParamName, optimus()->decode($routeParamValue)); } return $next($request); } } ================================================ FILE: app/Http/Middleware/RedirectIfAuthenticated.php ================================================ auth = $auth; } /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { if ($this->auth->check()) { return redirect('/home'); } return $next($request); } } ================================================ FILE: app/Http/Middleware/RefreshToken.php ================================================ auth->setRequest($request)->parseToken()->refresh(); return response()->json([ 'code' => 201, 'message' => 'success', 'token' => $newToken, ], 201, [ 'Authorization' => "Bearer {$newToken}", ]); } } ================================================ FILE: app/Http/Middleware/ThrottleApiRequests.php ================================================ resolveRequestSignature($request); if ($this->limiter->tooManyAttempts($key, $maxAttempts, $decayMinutes)) { return json()->setHeaders([ 'Retry-After' => $this->limiter->availableIn($key), 'X-RateLimit-Limit' => $maxAttempts, 'X-RateLimit-Remaining' => 0, ])->tooManyRequestsError(); } $this->limiter->hit($key, $decayMinutes); $response = $next($request); $response->headers->add([ 'X-RateLimit-Limit' => $maxAttempts, 'X-RateLimit-Remaining' => $maxAttempts - $this->limiter->attempts($key) + 1, ]); return $response; } } ================================================ FILE: app/Http/Middleware/VerifyCsrfToken.php ================================================ isDelete()) { $rules = []; } elseif ($this->isUpdate()) { $rules = ['tags' => ['array']]; } else { $rules = [ 'title' => 'required', 'content' => 'required', 'tags' => 'required|array' ]; } return $rules; } } ================================================ FILE: app/Http/Requests/FilterArticlesRequest.php ================================================ "in:{$filters}", // Query scope filter $params['limit'] => 'size:1,10', // PerPage $params['sort'] => 'in:created_at,view_count,created', // Sort: Age(created_at), View(view_count) $params['order'] => 'in:asc,desc', // Direction: Ascending or Descending $params['search'] => 'alpha_dash', // Search query $params['page'] => '', // Page number ]; } } ================================================ FILE: app/Http/Requests/Request.php ================================================ input('_method')), $needle) or in_array(strtolower($this->header('x-http-method-override')), $needle) or in_array(strtolower($this->method()), $needle); } /** * Determine if the request is delete * * @return bool */ protected function isDelete() { $needle = ['delete']; return in_array(strtolower($this->input('_method')), $needle) or in_array(strtolower($this->header('x-http-method-override')), $needle) or in_array(strtolower($this->method()), $needle); } /** * {@inheritDoc} */ public function response(array $errors) { if (is_api_request()) { return json()->unprocessableError($errors); } if ($this->ajax() || $this->wantsJson()) { return new JsonResponse($errors, 422); } return $this->redirector->to($this->getRedirectUrl()) ->withInput($this->except($this->dontFlash)) ->withErrors($errors, $this->errorBag); } /** * {@inheritDoc} */ public function forbiddenResponse() { if (is_api_request()) { return json()->forbiddenError(); } return response('Forbidden', 403); } } ================================================ FILE: app/Http/routes.php ================================================ config('project.api_domain'), 'as' => 'api.', 'namespace' => 'Api', 'middleware' => 'cors'], function() { /* Landing page */ Route::get('/', [ 'as' => 'index', 'uses' => 'WelcomeController@index' ]); /* User Registration */ Route::post('auth/register', [ 'as' => 'users.store', 'uses' => 'UsersController@store' ]); /* Session. * In API, logout path is not required. Because, * when token is expired, any API request will not be validated. */ Route::post('auth/login', [ 'as' => 'sessions.store', 'uses' => 'SessionsController@store' ]); Route::post('auth/refresh', [ 'as' => 'sessions.refresh', 'uses' => 'SessionsController@refresh' ]); /* Social Login * In API, social login is not provided. * Each client has to integrate an Oauth library, and * call 'POST auth/register' route in a onOauthLoginSuccessCallback. */ /* Password Reminder. * Password reset is possible only through the web page. * For api client, this endpoint will accept user's email address * and send the user email which contains password reset token. */ Route::post('auth/remind', [ 'as' => 'remind.store', 'uses' => 'PasswordsController@postRemind', ]); /* User */ Route::resource('users', 'UsersController', ['only' => ['show']]); /* api.v1 */ Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function() { /* Landing page */ Route::get('/', [ 'as' => 'v1.index', 'uses' => 'WelcomeController@index' ]); /* Forum */ Route::get('tags/{slug}/articles', [ 'as' => 'v1.tags.articles.index', 'uses' => 'ArticlesController@index' ]); Route::resource('articles', 'ArticlesController', ['except' => ['create', 'edit']]); Route::resource('comments', 'CommentsController', ['except' => ['create', 'edit']]); }); }); Route::group(['domain' => config('project.app_domain')], function() { /* Landing page */ Route::get('/', [ 'as' => 'index', 'uses' => 'WelcomeController@index', ]); Route::get('home', [ 'as' => 'home', 'uses' => 'WelcomeController@home', ]); Route::get('locale', [ 'as' => 'locale', 'uses' => 'WelcomeController@locale', ]); /* Mailing list */ //Route::post('mail-list/subscribe', [ // 'as' => 'mail-list.subscribe', // 'uses' => 'MailListController@subscribe', //]); // //Route::delete('mail-list/unsubscribe', [ // 'as' => 'mail-list.unsubscribe', // 'uses' => 'MailListController@unsubscribe', //]); /* Documents */ Route::get('lessons/{image}', [ 'as' => 'lessons.image', 'uses' => 'LessonsController@image', ]); Route::get('lessons/{file?}', [ 'as' => 'lessons.show', 'uses' => 'LessonsController@show', ]); /* Forum */ Route::get('tags/{slug}/articles', [ 'as' => 'tags.articles.index', 'uses' => 'ArticlesController@index' ]); Route::put('articles/{articles}/pick', [ 'as' => 'articles.pick-best-comment', 'uses' => 'ArticlesController@pickBest' ]); Route::resource('articles', 'ArticlesController'); /* Attachments */ Route::resource('files', 'AttachmentsController', ['only' => ['store', 'destroy']]); /* Comments */ Route::post('comments/{id}/vote', 'CommentsController@vote'); Route::resource('comments', 'CommentsController', ['only' => ['store', 'update', 'destroy']]); /* User Registration */ Route::get('auth/register', [ 'as' => 'users.create', 'uses' => 'UsersController@create' ]); Route::post('auth/register', [ 'as' => 'users.store', 'uses' => 'UsersController@store' ]); /* Social Login */ Route::get('social/{provider}', [ 'as' => 'social.login', 'uses' => 'SocialController@execute', ]); /* Session */ Route::get('auth/login', [ 'as' => 'sessions.create', 'uses' => 'SessionsController@create' ]); Route::post('auth/login', [ 'as' => 'sessions.store', 'uses' => 'SessionsController@store' ]); Route::get('auth/logout', [ 'as' => 'sessions.destroy', 'uses' => 'SessionsController@destroy' ]); /* Password Reminder */ Route::get('auth/remind', [ 'as' => 'remind.create', 'uses' => 'PasswordsController@getRemind', ]); Route::post('auth/remind', [ 'as' => 'remind.store', 'uses' => 'PasswordsController@postRemind', ]); Route::get('auth/reset/{token}', [ 'as' => 'reset.create', 'uses' => 'PasswordsController@getReset', ]); Route::post('auth/reset', [ 'as' => 'reset.store', 'uses' => 'PasswordsController@postReset', ]); }); /* From Laravel 5.2 all built-in events are fired in the form of Object */ //DB::listen(function($event){ // var_dump($event->sql/*, $event->bindings, $event->time*/); //}); ================================================ FILE: app/Jobs/Job.php ================================================ belongsTo(User::class, 'author_id'); } public function comments() { return $this->morphMany(Comment::class, 'commentable'); } } ================================================ FILE: app/Listeners/.gitkeep ================================================ ================================================ FILE: app/Listeners/CacheHandler.php ================================================ cacheTags)->flush(); } } ================================================ FILE: app/Listeners/CommentsHandler.php ================================================ commentable->notification) { // get the Article author's email and append to the recipients array. $this->to[] = $comment->commentable->author->email; } // get email address lists from the comments and append to the recipients array. $this->findEmail($comment); // Remove duplicate email address. $to = array_unique($this->to); $subject = 'New comment'; return \Mail::send('emails.new-comment', compact('comment'), function($m) use($to, $subject) { return $m->to($to)->subject($subject); }); } /** * Recursively find email address from the comments and push them to recipients list. * * @param \App\Comment $comment */ protected function findEmail(Comment $comment) { if ($comment->parent) { $this->to[] = $comment->parent->author->email; return $this->findEmail($comment->parent); } return; } } ================================================ FILE: app/Listeners/UserEventsHandler.php ================================================ listen( 'users.login', __CLASS__ . '@onUserLogin' ); } /** * User login event handler * * @param \App\User $user */ public function onUserLogin(User $user) { // Update last_login field $user->last_login = \Carbon\Carbon::now()->toDateTimeString(); $user->save(); } } ================================================ FILE: app/Listeners/ViewCountHandler.php ================================================ article->view_count ++; $event->article->save(); } } ================================================ FILE: app/Model.php ================================================ cookie('locale__myProject')) { app()->setLocale(\Crypt::decrypt($locale)); } \Carbon\Carbon::setLocale(app()->getLocale()); } /** * Register any application services. * * @return void */ public function register() { $this->app->singleton(\Jenssegers\Optimus\Optimus::class, function () { return new \Jenssegers\Optimus\Optimus(132961291, 1484265379, 37817169); }); if ($this->app->environment('local')) { $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class); } } } ================================================ FILE: app/Providers/AuthServiceProvider.php ================================================ 'App\Policies\ModelPolicy', ]; /** * Register any application authentication / authorization services. * * @param \Illuminate\Contracts\Auth\Access\Gate $gate * @return void */ public function boot(GateContract $gate) { $this->registerPolicies($gate); // } } ================================================ FILE: app/Providers/EventServiceProvider.php ================================================ [ \App\Listeners\CacheHandler::class ], \App\Events\ArticleConsumed::class => [ \App\Listeners\ViewCountHandler::class ] ]; /** * Register any other events for your application. * * @param \Illuminate\Contracts\Events\Dispatcher $events * @return void */ public function boot(DispatcherContract $events) { parent::boot($events); $events->listen('comments.*', \App\Listeners\CommentsHandler::class); $events->subscribe(\App\Listeners\UserEventsHandler::class); } } ================================================ FILE: app/Providers/RouteServiceProvider.php ================================================ pattern('id', '[0-9]+'); // Should handle 32n33-sss-img-dd.png exception $router->pattern('image', 'images/(?P[0-9n]{2,5}-[\pL-\pN\._-]+)-(?Pimg-[0-9]{2}.png)'); parent::boot($router); } /** * Define the routes for the application. * * @param \Illuminate\Routing\Router $router * @return void */ public function map(Router $router) { $router->group(['namespace' => $this->namespace], function ($router) { require app_path('Http/routes.php'); }); } } ================================================ FILE: app/Reporters/ErrorReport.php ================================================ primitive = $e; $webhook = $webhook ?: env('SLACK_WEBHOOK'); $this->client = $this->createClient($webhook, $settings); } /** * Send slack report. * * @return mixed */ public function send() { return $this->client->createMessage()->attach($this->buildPayload())->send(); } /** * Build Slack Attachment array based on given Exception object. * * @see https://api.slack.com/docs/attachments * * @return array */ protected function buildPayload() { return new Attachment([ 'fallback' => 'Error Report', 'text' => $this->primitive->getMessage() ?: "Something broken :(", 'color' => 'danger', 'fields' => [ new AttachmentField([ 'title' => 'localtime', 'value' => Carbon::now('Asia/Seoul')->toDateTimeString(), ]), new AttachmentField([ 'title' => 'username', 'value' => (auth()->check() ? auth()->user()->email : 'Unknown') . sprintf(' (%s)', Request::ip()), ]), new AttachmentField([ 'title' => 'route', 'value' => Route::currentRouteName() . sprintf( ' (%s %s)', Request::method(), Request::fullUrl() ), ]), new AttachmentField([ 'title' => 'description', 'value' => sprintf( '%s in %s line %d', get_class($this->primitive), pathinfo($this->primitive->getFile(), PATHINFO_BASENAME), $this->primitive->getLine() ), ]), new AttachmentField([ 'title' => 'trace', 'value' => $this->primitive->getTraceAsString(), ]), ], ]); } /** * Factory - Create HTTP API Client for Slack * * @param array $overrides * @return \Maknz\Slack\Client */ protected function createClient($webhook, $overrides = []) { $settings = array_merge([ 'channel' => '#l5essential', 'username' => 'aws-demo', 'link_names' => true, 'unfurl_links' => true, 'markdown_in_attachments' => ['title', 'text', 'fields'], ], $overrides); return new Client($webhook, $settings); } } ================================================ FILE: app/Reporters/MonologSlackReport.php ================================================ createLogger(); } /** * Send slack report. * * @param \Exception $e * @return mixed */ public function send(\Exception $e) { return $this->logger->error( $e->getMessage() ?: "Something broken :(", $this->buildPayload($e) ); } /** * Build payload array based on given Exception object. * * @param \Exception $e * @return array */ protected function buildPayload(\Exception $e) { return [ 'username' => auth()->check() ? auth()->user()->email : 'Unknown', 'route' => \Route::currentRouteName(), 'localtime' => \Carbon\Carbon::now('Asia/Seoul')->toDateTimeString(), 'exception' => [ 'class' => get_class($e), 'file' => $e->getFile(), 'line' => $e->getLine(), 'message' => $e->getMessage(), 'code' => $e->getCode(), 'trace' => $e->getTraceAsString(), 'ip' => \Request::ip(), 'method' => \Request::method(), 'url' => \Request::fullUrl(), 'content' => \Request::instance()->getContent() ?: json_encode(\Request::all()), 'headers' => \Request::header(), ], ]; } /** * Factory - Create Monolog instance which has a Slack handler. */ protected function createLogger() { $logger = \Log::getMonolog(); $logger->pushHandler( (new \Monolog\Handler\SlackHandler(env('SLACK_TOKEN'), '#l5essential', 'aws-demo')) ->setLevel(\Monolog\Logger::ERROR) ); return $this->logger = $logger; } } ================================================ FILE: app/Repositories/LessonRepository.php ================================================ find('INDEX.md'); } /** * Specify an Eloquent Model's class name. * * @return string */ public function model() { return \App\Lesson::class; } } ================================================ FILE: app/Repositories/MarkdownRepository.php ================================================ initialize(); } /** * Specify an Eloquent Model's class name. * * @return string */ public abstract function model(); /** * Factory - new up the model and set the required properties. * * @return \Illuminate\Database\Eloquent\Model * @throws \Exception */ protected function initialize() { $model = app()->make($this->model()); if (! $model instanceof Model) { throw new Exception( 'model() method must return a string name of an Eloquent Model. Or the provided model cannot be instantiable.' ); } if (! property_exists($this->model(), 'path')) { throw new Exception( "{$this->model()} should have a property named 'path'" ); } $path = base_path($model::$path); if (! File::isDirectory($path)) { throw new Exception( "Something went wrong with the path property of {$this->model()} model." ); } $this->model = $model; $this->path = $path; if (! $this->toc) { // Todo Expensive job. Should apply cache.. $this->toc = Cache::remember('lessons.index', 120, function() use($model) { $all = glob(base_path($model::$path . DIRECTORY_SEPARATOR . '*.md')); $excepts = []; if (property_exists($this->model(), 'excepts')) { foreach ($model::$excepts as $except) { $excepts[] = base_path($model::$path . DIRECTORY_SEPARATOR . $except); } } $files = array_diff($all, $excepts); return array_map(function($file) { return pathinfo($file, PATHINFO_BASENAME); }, $files); }); } return; } /** * Get the collection of model. * * @param array $columns * @return \Illuminate\Database\Eloquent\Collection */ public function all($columns = ['*']) { return $this->model->get($columns); } /** * Get the table of contents. * * @return array */ public function toc() { return $this->toc; } /** * Get the currently selected markdown's filename. * * @return string */ public function current() { return $this->current; } /** * Calculate previous entry. * * @param $current * @return bool */ public function prev($current) { $prev = array_search($current, $this->toc) - 1; return array_key_exists($prev, $this->toc) ? $this->toc[$prev] : false; } /** * Calculate next entry. * * @param $current * @return mixed */ public function next($current) { $next = array_search($current, $this->toc) + 1; return array_key_exists($next, $this->toc) ? $this->toc[$next] : false; } /** * Get the model instance. * * @param mixed $id filename * @param array $columns * @return \Illuminate\Database\Eloquent\Model */ public function find($id, $columns = ['*']) { $this->current = $id; return $this->model->whereName($id)->first() ?: $this->model->create([ 'author_id' => 1, // Bad!! Avoid hard code, b.c, admin may change. 'name' => $id, 'content' => File::get($this->getPath($id)), ]); } /** * Calculate and respond image path. * * @param string $file * @return \Intervention\Image\Image */ public function image($file) { return Image::make($this->getPath($file)); } /** * Create etag value * * @param string $file * @return string */ public function etag($file) { return md5($file . '/' . File::lastModified($this->getPath($file))); } /** * Calculate full path to the given file. * * @param string $file * @return string */ protected function getPath($file) { $path = $this->path . DIRECTORY_SEPARATOR . $file; if (!File::exists($path)) { abort(404, 'File not exist'); } return $path; } } ================================================ FILE: app/Repositories/RepositoryInterface.php ================================================ \d+)/i'; const PATTERN_REMOVE = '/[\w\W\d\D]+/'; const PATTERN_FRONT_FORMATTER = '/---[\w\W\d\D]+extends[\w\W\d\D]+---/'; /** * Add link to another articles * * @param $text * @return mixed|string */ public function text($text) { if (preg_match(self::PATTERN_ARTICLE, $text, $matches) > 0) { $text = preg_replace_callback(self::PATTERN_ARTICLE, function ($matches) { return sprintf( "%s", route('articles.show', $matches['id']), $matches[0] ); }, $text); } if (preg_match(self::PATTERN_REMOVE, $text, $matches) > 0) { $text = preg_replace_callback(self::PATTERN_REMOVE, function ($matches) { return ''; }, $text); } if (preg_match(self::PATTERN_FRONT_FORMATTER, $text, $matches) > 0) { $text = preg_replace_callback(self::PATTERN_FRONT_FORMATTER, function ($matches) { return ''; }, $text); } return parent::text($text); } } ================================================ FILE: app/Tag.php ================================================ belongsToMany(Article::class)->withTimestamps(); } } ================================================ FILE: app/Transformers/ArticleTransformer.php ================================================ ?include=comments:limit(5|1):order(created_at|desc) * item case -> ?include=author * * @var array */ protected $availableIncludes = ['comments', 'author', 'tags', 'attachments']; /** * Transform single resource. * * @param \App\Article $article * @return array */ public function transform(Article $article) { $id = optimus((int) $article->id); $payload = [ 'id' => $id, 'title' => $article->title, 'content_raw' => strip_tags($article->content), 'content_html' => markdown($article->content), 'created' => $article->created_at->toIso8601String(), 'view_count' => (int) $article->view_count, 'link' => [ 'rel' => 'self', 'href' => route('api.v1.articles.show', $id), ], 'comments' => (int) $article->comments->count(), 'author' => [ 'name' => $article->author->name, 'email' => $article->author->email, 'avatar' => 'http:' . gravatar_profile_url($article->author->email), ], 'tags' => $article->tags->pluck('slug'), 'attachments' => (int) $article->attachments->count(), ]; if ($fields = $this->getPartialFields()) { $payload = array_only($payload, $fields); } return $payload; } /** * Include comments. * * @param \App\Article $article * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeComments(Article $article, ParamBag $params = null) { $transformer = new \App\Transformers\CommentTransformer($params); $parsed = $this->getParsedParams(); $comments = $article->comments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($comments, $transformer); } /** * Include author. * * @param \App\Article $article * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Item */ public function includeAuthor(Article $article, ParamBag $params = null) { return $this->item($article->author, new \App\Transformers\UserTransformer($params)); } /** * Include tags. * * @param \App\Article $article * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeTags(Article $article, ParamBag $params = null) { $transformer = new \App\Transformers\TagTransformer($params); $parsed = $this->getParsedParams(); $tags = $article->tags()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($tags, $transformer); } /** * Include attachments. * * @param \App\Article $article * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeAttachments(Article $article, ParamBag $params = null) { $transformer = new \App\Transformers\AttachmentTransformer($params); $parsed = $this->getParsedParams(); $attachments = $article->attachments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($attachments, $transformer); } } ================================================ FILE: app/Transformers/AttachmentTransformer.php ================================================ optimus((int) $attachment->id), 'name' => $attachment->name, 'created' => $attachment->created_at->toIso8601String(), 'link' => [ 'rel' => 'self', 'href' => url(sprintf('http://%s:8000/attachments/%s', config('project.app_domain'), $attachment->name)), ], ]; if ($fields = $this->getPartialFields()) { $payload = array_only($payload, $fields); } return $payload; } } ================================================ FILE: app/Transformers/CommentTransformer.php ================================================ ?include=comments:limit(5|1):order(created_at|desc) * item case -> ?include=author * * @var array */ protected $availableIncludes = ['author']; /** * Transform single resource. * * @param \App\Comment $comment * @return array */ public function transform(Comment $comment) { $id = optimus((int) $comment->id); $payload = [ 'id' => $id, 'content_raw' => strip_tags($comment->content), 'content_html' => markdown($comment->content), 'created' => $comment->created_at->toIso8601String(), 'vote' => ['up' => (int) $comment->up_count, 'down' => (int) $comment->down_count], 'link' => [ 'rel' => 'self', 'href' => route('api.v1.comments.show', $id), ], 'author' => [ 'name' => $comment->author->name, 'email' => $comment->author->email, 'avatar' => 'http:' . gravatar_profile_url($comment->author->email), ], ]; if ($fields = $this->getPartialFields()) { $payload = array_only($payload, $fields); } return $payload; } /** * Include author. * * @param \App\Comment $comment * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Item */ public function includeAuthor(Comment $comment, ParamBag $params = null) { return $this->item($comment->author, new \App\Transformers\UserTransformer($params)); } } ================================================ FILE: app/Transformers/TagTransformer.php ================================================ optimus((int) $tag->id), 'slug' => $tag->slug, 'created' => $tag->created_at->toIso8601String(), 'link' => [ 'rel' => 'self', 'href' => route('api.v1.tags.articles.index', $tag->slug), ], 'articles' => (int) $tag->articles->count(), ]; if ($fields = $this->getPartialFields()) { $payload = array_only($payload, $fields); } return $payload; } /** * Include articles. * * @param \App\Tag $tag * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeArticles(Tag $tag, ParamBag $params = null) { $transformer = new \App\Transformers\ArticleTransformer($params); $parsed = $this->getParsedParams(); $articles = $tag->articles()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($articles, $transformer); } } ================================================ FILE: app/Transformers/UserTransformer.php ================================================ ?include=comments:limit(5|1):order(created_at|desc) * item case -> ?include=author * * @var array */ protected $availableIncludes = ['articles', 'comments']; /** * Transform single resource. * * @param \App\User $user * @return array */ public function transform(User $user) { $id = optimus((int) $user->id); $payload = [ 'id' => $id, 'name' => $user->name, 'email' => $user->email, 'avatar' => 'http:' . gravatar_profile_url($user->email), 'signup' => $user->created_at->toIso8601String(), 'link' => [ 'rel' => 'self', 'href' => route('api.users.show', $id), ], 'articles' => (int) $user->articles->count(), 'comments' => (int) $user->comments->count(), ]; if ($fields = $this->getPartialFields()) { $payload = array_only($payload, $fields); } return $payload; } /** * Include articles. * * @param \App\User $user * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeArticles(User $user, ParamBag $params = null) { $transformer = new \App\Transformers\ArticleTransformer($params); $parsed = $this->getParsedParams(); $articles = $user->articles()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($articles, $transformer); } /** * Include comments. * * @param \App\User $user * @param \League\Fractal\ParamBag|null $params * @return \League\Fractal\Resource\Collection * @throws \Exception */ public function includeComments(User $user, ParamBag $params = null) { $transformer = new \App\Transformers\CommentTransformer($params); $parsed = $this->getParsedParams(); $comments = $user->comments()->limit($parsed['limit'])->offset($parsed['offset'])->orderBy($parsed['sort'], $parsed['order'])->get(); return $this->collection($comments, $transformer); } } ================================================ FILE: app/User.php ================================================ hasMany(Article::class, 'author_id'); } public function comments() { return $this->hasMany(Comment::class, 'author_id'); } public function votes() { return $this->hasMany(Vote::class); } /* Query Scopes */ public function scopeNoPassword($query) { return $query->whereNull('password'); } /* Helpers */ public function isAdmin() { return $this->roles()->whereSlug('admin')->exists(); } } ================================================ FILE: app/Vote.php ================================================ belongsTo(Comment::class); } public function user() { return $this->belongsTo(User::class); } } ================================================ FILE: app/helpers.php ================================================ text($text); } } if (! function_exists('icon')) { /** * Generate FontAwesome icon tag * * @param string $class font-awesome class name * @param string $addition additional class * @param string $inline inline style * @return string */ function icon($class, $addition = 'icon', $inline = null) { $icon = config('icons.' . $class); $inline = $inline ? 'style="' . $inline . '"' : null; return sprintf('', $icon, $addition, $inline); } } if (! function_exists('attachment_path')) { /** * @param string $path * * @return string */ function attachment_path($path = '') { return public_path($path ? 'attachments' . DIRECTORY_SEPARATOR . $path : 'attachments'); } } if (! function_exists('gravatar_profile_url')) { /** * Get gravatar profile page url * * @param string $email * @return string */ function gravatar_profile_url($email) { return sprintf("//www.gravatar.com/%s", md5($email)); } } if (! function_exists('gravatar_url')) { /** * Get gravatar image url * * @param string $email * @param integer $size * @return string */ function gravatar_url($email, $size = 48) { return sprintf("//www.gravatar.com/avatar/%s?s=%s", md5($email), $size); } } if (! function_exists('taggable')) { /** * Determine if the current cache driver has cacheTags() method * * @return bool */ function taggable() { return ! in_array(config('cache.default'), ['file', 'database']); } } if (! function_exists('link_for_sort')) { /** * Build HTML anchor tag for sorting * * @param string $column * @param string $text * @param array $params * @return string */ function link_for_sort($column, $text, $params = []) { $config = config('project.params'); $direction = request()->input($config['order']); $reverse = ($direction == 'asc') ? 'desc' : 'asc'; if (request()->input($config['sort']) == $column) { // Update passed $text var, only if it is active sort $text = sprintf( "%s %s", $direction == 'asc' ? icon('asc') : icon('desc'), $text ); } $queryString = http_build_query(array_merge( request()->except([$config['page'], $config['sort'], $config['order']]), [$config['sort'] => $column, $config['order'] => $reverse], $params )); return sprintf( '%s', urldecode(request()->url()), $queryString, $text ); } } if (! function_exists('current_url')) { /** * Build current url string, without return param. * * @return string */ function current_url() { if (! request()->has('return')) { return request()->fullUrl(); } return sprintf( '%s?%s', request()->url(), http_build_query(request()->except('return')) ); } } if (! function_exists('is_api_request')) { /** * Determine if the current request is for HTTP api. * * @return bool */ function is_api_request() { return starts_with(request()->getHttpHost(), config('project.api_domain')); } } if (! function_exists('optimus')) { /** * Create Optimus instance. * * @param int|null $id * @return int|\Jenssegers\Optimus\Optimus */ function optimus($id = null) { $factory = app(\Jenssegers\Optimus\Optimus::class); if (func_num_args() === 0) { return $factory; } return $factory->encode($id); } } if (! function_exists('cache_key')) { /** * Generate key for caching. * * Note. Even though the request endpoints are the same, * the response body should be different because of the query string. * * @param $base * @return string */ function cache_key($base) { $key = ($uri = request()->fullUrl()) ? $base . '.' . urlencode($uri) : $base; return md5($key); } } ================================================ FILE: artisan ================================================ #!/usr/bin/env php make(Illuminate\Contracts\Console\Kernel::class); $status = $kernel->handle( $input = new Symfony\Component\Console\Input\ArgvInput, new Symfony\Component\Console\Output\ConsoleOutput ); /* |-------------------------------------------------------------------------- | Shutdown The Application |-------------------------------------------------------------------------- | | Once Artisan has finished running. We will fire off the shutdown events | so that any final work may be done by the application before we shut | down the process. This is the last thing to happen to the request. | */ $kernel->terminate($input, $status); exit($status); ================================================ FILE: bootstrap/app.php ================================================ singleton( Illuminate\Contracts\Http\Kernel::class, App\Http\Kernel::class ); $app->singleton( Illuminate\Contracts\Console\Kernel::class, App\Console\Kernel::class ); $app->singleton( Illuminate\Contracts\Debug\ExceptionHandler::class, App\Exceptions\Handler::class ); /* |-------------------------------------------------------------------------- | Return The Application |-------------------------------------------------------------------------- | | This script returns the application instance. The instance is given to | the calling script so we can separate the building of the instances | from the actual running of the application and sending responses. | */ return $app; ================================================ FILE: bootstrap/autoload.php ================================================ =5.5.9", "laravel/framework": "5.2.*", "erusev/parsedown-extra": " 0.7.*", "intervention/image": "2.3.*", "laracasts/flash": "1.3.*", "laravel/socialite": "2.0.*", "bican/roles": " 2.1.*", "maknz/slack": "1.*", "tymon/jwt-auth": "0.5.*", "appkr/api": "0.1.*", "jenssegers/optimus": "^0.2.0", "asm89/stack-cors": "dev-master as 0.2.2", "barryvdh/laravel-cors": "^0.7.3" }, "require-dev": { "symfony/dom-crawler": "~3.0", "symfony/css-selector": "~3.0", "fzaninotto/faker": "~1.4", "mockery/mockery": "0.9.*", "phpunit/phpunit": "~4.0", "phpspec/phpspec": "~2.1", "barryvdh/laravel-ide-helper": "^2.1" }, "autoload": { "classmap": [ "database" ], "files": [ "app/helpers.php" ], "psr-4": { "App\\": "app/" } }, "autoload-dev": { "classmap": [ "tests/TestCase.php" ], "psr-4": { "Test\\": "tests/integration/" } }, "scripts": { "post-install-cmd": [ "php artisan clear-compiled", "php artisan optimize", "php artisan cache:clear" ], "pre-update-cmd": [ "php artisan clear-compiled" ], "post-update-cmd": [ "php artisan optimize", "php artisan cache:clear" ], "post-root-package-install": [ "php -r \"copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "php artisan key:generate" ] }, "config": { "preferred-install": "dist" } } ================================================ FILE: config/api.php ================================================ env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | API Endpoint pattern |-------------------------------------------------------------------------- | | Path 'pattern' used for is_api_request() Helper. | Provide 'domain', if the api routes are distinguished by domain name. | */ 'endpoint' => [ 'pattern' => 'v1/*', 'domain' => env('API_DOMAIN', 'api.myproject.dev'), ], /* |-------------------------------------------------------------------------- | Include by query string. |-------------------------------------------------------------------------- | | If you defined 'availableInclude' property and includeXxx methods | in a transformer, you can include sub resources using query string. | e.g. /authors?include=books:limit(3|0):order(id|desc) means | including 3 records of 'authors', which is reverse ordered by 'id' field, | without any skipping(0). | | An API client can pass list of includes using array or csv string format. | e.g. /authors?include[]=books:limit(2|0)&include[]=comments:order(id|asc) | /authors?include=books:limit(2|0),comments:order(id|asc) | | For sub-resource inclusion, client can use dot(.) notation. | e.g. /books?include=author,publisher.somethingelse | */ 'include' => [ 'key' => 'include', 'params' => [ // available modifier params and their default value 'limit' => [3, 0], // [limit, offset] 'sort' => ['created_at', 'desc'], // [sortKey, sortDirection] ], ], /* |-------------------------------------------------------------------------- | Partial response |-------------------------------------------------------------------------- | | API clients are allowed to select the response format using query string. | This will help saving network bandwidth.. | e.g. /author?fields=id,title,link&include=books:fields(id|title|published_at) | */ 'partial' => [ 'key' => 'fields', ], /* |-------------------------------------------------------------------------- | Transformer directory and namespace. |-------------------------------------------------------------------------- | | Below config will be applied when we run 'make:transformer' artisan cmd. | The generated class will be saved at 'dir', and namespaced as you set. | Note that the 'dir' should be relative to the project root. | */ 'transformer' => [ 'dir' => 'app/Transformers', 'namespace' => 'App\\Transformers', ], /* |-------------------------------------------------------------------------- | Fractal Serializer |-------------------------------------------------------------------------- | | Refer to | http://fractal.thephpleague.com/serializers/ | */ 'serializer' => \League\Fractal\Serializer\ArraySerializer::class, /* |-------------------------------------------------------------------------- | Default Response Headers |-------------------------------------------------------------------------- | | Default response headers that every resource/simple response should includes | */ 'defaultHeaders' => ['X-Powered-By' => 'appkr/api'], /* |-------------------------------------------------------------------------- | Suppress HTTP status code |-------------------------------------------------------------------------- | | If set to true, the status code will be fixed to 200. | */ 'suppress_response_code' => false, /* |-------------------------------------------------------------------------- | Success Response Format |-------------------------------------------------------------------------- | | The format will be used at the ApiResponse to respond with success message. | respondNoContent(), respondSuccess(), respondCreated() consumes this format | */ 'successFormat' => [ 'success' => [ 'code' => ':code', 'message' => ':message', ] ], /* |-------------------------------------------------------------------------- | Error Response Format |-------------------------------------------------------------------------- | | The format will be used at the ApiResponse to respond with error message. | respondWithError(), respondForbidden()... consumes this format | */ 'errorFormat' => [ 'error' => [ 'code' => ':code', 'message' => ':message', ] ] ]; ================================================ FILE: config/app.php ================================================ env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. | */ 'url' => env('APP_URL', 'http://localhost:8000'), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => 'ko', /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY', 'SomeRandomString'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Logging Configuration |-------------------------------------------------------------------------- | | Here you may configure the log settings for your application. Out of | the box, Laravel uses the Monolog PHP logging library. This gives | you a variety of powerful log handlers / formatters to utilize. | | Available Settings: "single", "daily", "syslog", "errorlog" | */ 'log' => env('APP_LOG', 'single'), /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ // Illuminate\Foundation\Providers\ArtisanServiceProvider::class, // removed while migrating to 5.2 Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, // Illuminate\Routing\ControllerServiceProvider::class, // removed while migrating to 5.2 Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, /* * 3rd Party Service Providers */ Intervention\Image\ImageServiceProvider::class, Laracasts\Flash\FlashServiceProvider::class, Laravel\Socialite\SocialiteServiceProvider::class, Bican\Roles\RolesServiceProvider::class, Maknz\Slack\SlackServiceProvider::class, Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class, Appkr\Api\ApiServiceProvider::class, Barryvdh\Cors\ServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Input' => Illuminate\Support\Facades\Input::class, 'Inspiring' => Illuminate\Foundation\Inspiring::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Password' => Illuminate\Support\Facades\Password::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, /* * 3rd Party Facade */ 'Image' => Intervention\Image\Facades\Image::class, 'Flash' => Laracasts\Flash\Flash::class, 'Socialite' => Laravel\Socialite\Facades\Socialite::class, 'Slack' => Maknz\Slack\Facades\Slack::class, 'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class, 'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class, ], ]; ================================================ FILE: config/auth.php ================================================ [ 'guard' => 'web', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session", "token" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'token', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | Here you may set the options for resetting passwords including the view | that is your password reset e-mail. You may also set the name of the | table that maintains all of the reset tokens for your application. | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that the reset token should be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'email' => 'emails.password', 'table' => 'password_resets', 'expire' => 60, ], ], ]; ================================================ FILE: config/broadcasting.php ================================================ env('BROADCAST_DRIVER', 'pusher'), /* |-------------------------------------------------------------------------- | Broadcast Connections |-------------------------------------------------------------------------- | | Here you may define all of the broadcast connections that will be used | to broadcast events to other systems or over websockets. Samples of | each available type of connection are provided inside this array. | */ 'connections' => [ 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_KEY'), 'secret' => env('PUSHER_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ // ], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', ], 'log' => [ 'driver' => 'log', ], ], ]; ================================================ FILE: config/cache.php ================================================ env('CACHE_DRIVER', 'file'), /* |-------------------------------------------------------------------------- | Cache Stores |-------------------------------------------------------------------------- | | Here you may define all of the cache "stores" for your application as | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | */ 'stores' => [ 'apc' => [ 'driver' => 'apc', ], 'array' => [ 'driver' => 'array', ], 'database' => [ 'driver' => 'database', 'table' => 'cache', 'connection' => null, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache'), ], 'memcached' => [ 'driver' => 'memcached', 'servers' => [ [ 'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100, ], ], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', ], ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing a RAM based store such as APC or Memcached, there might | be other applications utilizing the same cache. So, we'll specify a | value to get prefixed to all our keys so we can avoid collisions. | */ 'prefix' => 'laravel', ]; ================================================ FILE: config/compile.php ================================================ [ // ], /* |-------------------------------------------------------------------------- | Compiled File Providers |-------------------------------------------------------------------------- | | Here you may list service providers which define a "compiles" function | that returns additional files that should be compiled, providing an | easy way to get common files from any packages you are utilizing. | */ 'providers' => [ // ], ]; ================================================ FILE: config/cors.php ================================================ false, 'allowedOrigins' => ['*'], 'allowedHeaders' => ['*'], 'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE'], 'exposedHeaders' => [], 'maxAge' => 0, 'hosts' => [], ]; ================================================ FILE: config/database.php ================================================ PDO::FETCH_CLASS, /* |-------------------------------------------------------------------------- | Default Database Connection Name |-------------------------------------------------------------------------- | | Here you may specify which of the database connections below you wish | to use as your default connection for all database work. Of course | you may use many connections at once using the Database library. | */ 'default' => env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'testing' => [ 'driver' => 'sqlite', 'database' => ':memory:', ], 'sqlite' => [ 'driver' => 'sqlite', 'database' => base_path('tests/database.sqlite'), 'prefix' => '', ], 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', 'localhost'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'strict' => false, ], 'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', 'localhost'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'schema' => 'public', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'host' => env('DB_HOST', 'localhost'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer set of commands than a typical key-value systems | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'cluster' => false, 'default' => [ 'host' => '127.0.0.1', 'port' => 6379, 'database' => 0, ], ], ]; ================================================ FILE: config/filesystems.php ================================================ 'local', /* |-------------------------------------------------------------------------- | Default Cloud Filesystem Disk |-------------------------------------------------------------------------- | | Many applications store files both locally and in the cloud. For this | reason, you may specify a default "cloud" driver here. This driver | will be bound as the Cloud disk implementation in the container. | */ 'cloud' => 's3', /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Here you may configure as many filesystem "disks" as you wish, and you | may even configure multiple disks of the same driver. Defaults have | been setup for each driver as an example of the required options. | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), ], 'ftp' => [ 'driver' => 'ftp', 'host' => 'ftp.example.com', 'username' => 'your-username', 'password' => 'your-password', // Optional FTP Settings... // 'port' => 21, // 'root' => '', // 'passive' => true, // 'ssl' => true, // 'timeout' => 30, ], 's3' => [ 'driver' => 's3', 'key' => 'your-key', 'secret' => 'your-secret', 'region' => 'your-region', 'bucket' => 'your-bucket', ], 'rackspace' => [ 'driver' => 'rackspace', 'username' => 'your-username', 'key' => 'your-key', 'container' => 'your-container', 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', 'region' => 'IAD', 'url_type' => 'publicURL', ], ], ]; ================================================ FILE: config/icons.php ================================================ 'fa fa-sign-in', 'github' => 'fa fa-github', 'book' => 'fa fa-book', 'locale' => 'fa fa-language', 'certificate' => 'fa fa-certificate', 'forum' => 'fa fa-weixin', 'logout' => 'fa fa-sign-out', 'user' => 'fa fa-user', 'plane' => 'fa fa-paper-plane', 'check' => 'fa fa-check', 'clock' => 'fa fa-clock-o', 'tags' => 'fa fa-tags', 'comments' => 'fa fa-comments', 'reset' => 'fa fa-refresh', 'pencil' => 'fa fa-pencil', 'delete' => 'fa fa-trash-o', 'clip' => 'fa fa-paperclip', 'download' => 'fa fa-cloud-download', 'dropdown' => 'fa fa-chevron-down', 'update' => 'fa fa-paragraph', 'reply' => 'fa fa-reply', 'preview' => 'fa fa-code', 'up' => 'fa fa-chevron-up', 'down' => 'fa fa-chevron-down', 'filter' => 'fa fa-filter', 'view_count' => 'fa fa-eye', 'sort' => 'fa fa-sort', 'desc' => 'fa fa-sort-alpha-desc', 'asc' => 'fa fa-sort-alpha-asc', 'new' => 'fa fa-plus-circle', 'notice' => 'fa fa-bullhorn', 'pin' => 'fa fa-thumb-tack', ]; ================================================ FILE: config/jwt.php ================================================ env('JWT_SECRET', 'SomeRandomString'), /* |-------------------------------------------------------------------------- | JWT time to live |-------------------------------------------------------------------------- | | Specify the length of time (in minutes) that the token will be valid for. | Defaults to 1 hour | */ 'ttl' => 120, /* |-------------------------------------------------------------------------- | Refresh time to live |-------------------------------------------------------------------------- | | Specify the length of time (in minutes) that the token can be refreshed | within. I.E. The user can refresh their token within a 2 week window of | the original token being created until they must re-authenticate. | Defaults to 2 weeks | */ 'refresh_ttl' => 20160, /* |-------------------------------------------------------------------------- | JWT hashing algorithm |-------------------------------------------------------------------------- | | Specify the hashing algorithm that will be used to sign the token. | | See here: https://github.com/namshi/jose/tree/2.2.0/src/Namshi/JOSE/Signer | for possible values | */ 'algo' => 'HS256', /* |-------------------------------------------------------------------------- | User Model namespace |-------------------------------------------------------------------------- | | Specify the full namespace to your User model. | e.g. 'Acme\Entities\User' | */ 'user' => App\User::class, /* |-------------------------------------------------------------------------- | User identifier |-------------------------------------------------------------------------- | | Specify a unique property of the user that will be added as the 'sub' | claim of the token payload. | */ 'identifier' => 'id', /* |-------------------------------------------------------------------------- | Required Claims |-------------------------------------------------------------------------- | | Specify the required claims that must exist in any token. | A TokenInvalidException will be thrown if any of these claims are not | present in the payload. | */ 'required_claims' => ['iss', 'iat', 'exp', 'nbf', 'sub', 'jti'], /* |-------------------------------------------------------------------------- | Blacklist Enabled |-------------------------------------------------------------------------- | | In order to invalidate tokens, you must have the the blacklist enabled. | If you do not want or need this functionality, then set this to false. | */ 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), /* |-------------------------------------------------------------------------- | Providers |-------------------------------------------------------------------------- | | Specify the various providers used throughout the package. | */ 'providers' => [ /* |-------------------------------------------------------------------------- | User Provider |-------------------------------------------------------------------------- | | Specify the provider that is used to find the user based | on the subject claim | */ 'user' => Tymon\JWTAuth\Providers\User\EloquentUserAdapter::class, /* |-------------------------------------------------------------------------- | JWT Provider |-------------------------------------------------------------------------- | | Specify the provider that is used to create and decode the tokens. | */ 'jwt' => Tymon\JWTAuth\Providers\JWT\NamshiAdapter::class, /* |-------------------------------------------------------------------------- | Authentication Provider |-------------------------------------------------------------------------- | | Specify the provider that is used to authenticate users. | */ 'auth' => function ($app) { return new Tymon\JWTAuth\Providers\Auth\IlluminateAuthAdapter($app['auth']); }, /* |-------------------------------------------------------------------------- | Storage Provider |-------------------------------------------------------------------------- | | Specify the provider that is used to store tokens in the blacklist | */ 'storage' => function ($app) { return new Tymon\JWTAuth\Providers\Storage\IlluminateCacheAdapter($app['cache']); } ] ]; ================================================ FILE: config/mail.php ================================================ env('MAIL_DRIVER', 'smtp'), /* |-------------------------------------------------------------------------- | SMTP Host Address |-------------------------------------------------------------------------- | | Here you may provide the host address of the SMTP server used by your | applications. A default option is provided that is compatible with | the Mailgun mail service which will provide reliable deliveries. | */ 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), /* |-------------------------------------------------------------------------- | SMTP Host Port |-------------------------------------------------------------------------- | | This is the SMTP port used by your application to deliver e-mails to | users of the application. Like the host we have set this value to | stay compatible with the Mailgun e-mail application by default. | */ 'port' => env('MAIL_PORT', 587), /* |-------------------------------------------------------------------------- | Global "From" Address |-------------------------------------------------------------------------- | | You may wish for all e-mails sent by your application to be sent from | the same address. Here, you may specify a name and address that is | used globally for all e-mails that are sent by your application. | */ 'from' => ['address' => 'john@example.com', 'name' => 'John Doe'], /* |-------------------------------------------------------------------------- | E-Mail Encryption Protocol |-------------------------------------------------------------------------- | | Here you may specify the encryption protocol that should be used when | the application send e-mail messages. A sensible default using the | transport layer security protocol should provide great security. | */ 'encryption' => env('MAIL_ENCRYPTION', 'tls'), /* |-------------------------------------------------------------------------- | SMTP Server Username |-------------------------------------------------------------------------- | | If your SMTP server requires a username for authentication, you should | set it here. This will get used to authenticate with your server on | connection. You may also set the "password" value below this one. | */ 'username' => env('MAIL_USERNAME'), /* |-------------------------------------------------------------------------- | SMTP Server Password |-------------------------------------------------------------------------- | | Here you may set the password required by your SMTP server to send out | messages from your application. This will be given to the server on | connection so that the application will be able to send messages. | */ 'password' => env('MAIL_PASSWORD'), /* |-------------------------------------------------------------------------- | Sendmail System Path |-------------------------------------------------------------------------- | | When using the "sendmail" driver to send e-mails, we will need to know | the path to where Sendmail lives on this server. A default path has | been provided here, which will work well on most of your systems. | */ 'sendmail' => '/usr/sbin/sendmail -bs', /* |-------------------------------------------------------------------------- | Mail "Pretend" |-------------------------------------------------------------------------- | | When this option is enabled, e-mail will not actually be sent over the | web and will instead be written to your application's logs files so | you may inspect the message. This is great for local development. | */ 'pretend' => env('MAIL_PRETEND', false), ]; ================================================ FILE: config/project.php ================================================ ! env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Project identification |-------------------------------------------------------------------------- | */ 'name' => 'l5essential', 'description' => 'Laravel 5 입문 및 실전 강좌', 'app_domain' => env('APP_DOMAIN', 'myproject.dev'), 'api_domain' => env('API_DOMAIN', 'api.myproject.dev'), /* |-------------------------------------------------------------------------- | People & contacts |-------------------------------------------------------------------------- | */ 'contacts' => [ 'author' => [ 'name' => 'appkr', 'email' => 'juwonkim@me.com', 'url' => '', 'organization' => 'Appkr Studio', ], 'admin' => [ // ... ], ], /* |-------------------------------------------------------------------------- | SEO keys |-------------------------------------------------------------------------- | | @see https://www.google.com/webmasters/tools/home | Note. Is not required when Google Analytics is activated. | @see http://webmastertool.naver.com/board/main.naver | */ 'seo' => [ 'google_site_key' => 'ToXKBimREnz49pDNot4b-N9ZJgYcKXPPsHsjhg4Zzuc', 'naver_site_key' => '7cebcc8e5493169f5401870d9ce57f48d18491cd', ], /* |-------------------------------------------------------------------------- | Available/allowed fields for query string |-------------------------------------------------------------------------- | */ 'params' => [ 'page' => 'page', 'filter' => 'filter', 'limit' => 'limit', 'sort' => 'sort', 'order' => 'order', 'search' => 'q', 'select' => 'fields', ], /* |-------------------------------------------------------------------------- | Available/allowed value for 'filter' query string |-------------------------------------------------------------------------- | | 'model_in_lower_case' => ['filter_key' => 'description'], | filter_key will be transformed to camelCase when calling the scope query. | e.g. no_comment -> noComment | */ 'filters' => [ 'article' => [ 'no_comment' => 'No Comment', 'not_solved' => 'Not Solved' ] ], /* |-------------------------------------------------------------------------- | Available/allowed tags for/associated with Article |-------------------------------------------------------------------------- | | Used in database/seeds/DatabaseSeeder.php | */ 'tags' => [ 'General', 'Laravel', 'Lumen', 'Eloquent', 'Servers', 'Tips', 'Lesson Feedback' ], ]; ================================================ FILE: config/queue.php ================================================ env('QUEUE_DRIVER', 'sync'), /* |-------------------------------------------------------------------------- | Queue Connections |-------------------------------------------------------------------------- | | Here you may configure the connection information for each server that | is used by your application. A default configuration has been added | for each back-end shipped with Laravel. You are free to add more. | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'expire' => 60, ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => 'localhost', 'queue' => 'default', 'ttr' => 60, ], 'sqs' => [ 'driver' => 'sqs', 'key' => 'your-public-key', 'secret' => 'your-secret-key', 'queue' => 'your-queue-url', 'region' => 'us-east-1', ], 'iron' => [ 'driver' => 'iron', 'host' => 'mq-aws-us-east-1.iron.io', 'token' => 'your-token', 'project' => 'your-project-id', 'queue' => 'your-queue-name', 'encrypt' => true, ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => 'default', 'expire' => 60, ], ], /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | | These options configure the behavior of failed queue job logging so you | can control which database and table are used to store the jobs that | have failed. You may change them to any database / table you wish. | */ 'failed' => [ 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'failed_jobs', ], ]; ================================================ FILE: config/roles.php ================================================ null, /* |-------------------------------------------------------------------------- | Slug Separator |-------------------------------------------------------------------------- | | Here you can change the slug separator. This is very important in matter | of magic method __call() and also a `Slugable` trait. The default value | is a dot. | */ 'separator' => '.', /* |-------------------------------------------------------------------------- | Models |-------------------------------------------------------------------------- | | If you want, you can replace default models from this package by models | you created. Have a look at `Bican\Roles\Models\Role` model and | `Bican\Roles\Models\Permission` model. | */ 'models' => [ 'role' => Bican\Roles\Models\Role::class, 'permission' => Bican\Roles\Models\Permission::class, ], /* |-------------------------------------------------------------------------- | Roles, Permissions and Allowed "Pretend" |-------------------------------------------------------------------------- | | You can pretend or simulate package behavior no matter what is in your | database. It is really useful when you are testing you application. | Set up what will methods is(), can() and allowed() return. | */ 'pretend' => [ 'enabled' => false, 'options' => [ 'is' => true, 'can' => true, 'allowed' => true, ], ], ]; ================================================ FILE: config/services.php ================================================ [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), ], 'mandrill' => [ 'secret' => env('MANDRILL_SECRET'), ], 'ses' => [ 'key' => env('SES_KEY'), 'secret' => env('SES_SECRET'), 'region' => 'us-east-1', ], 'stripe' => [ 'model' => App\User::class, 'key' => env('STRIPE_KEY'), 'secret' => env('STRIPE_SECRET'), ], 'github' => [ 'client_id' => env('GITHUB_ID'), 'client_secret' => env('GITHUB_SECRET'), 'redirect' => env('GITHUB_CALLBACK'), ], ]; ================================================ FILE: config/session.php ================================================ env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- | Session Lifetime |-------------------------------------------------------------------------- | | Here you may specify the number of minutes that you wish the session | to be allowed to remain idle before it expires. If you want them | to immediately expire on the browser closing, set that option. | */ 'lifetime' => 120, 'expire_on_close' => false, /* |-------------------------------------------------------------------------- | Session Encryption |-------------------------------------------------------------------------- | | This option allows you to easily specify that all of your session data | should be encrypted before it is stored. All encryption will be run | automatically by Laravel and you can use the Session like normal. | */ 'encrypt' => false, /* |-------------------------------------------------------------------------- | Session File Location |-------------------------------------------------------------------------- | | When using the native session driver, we need a location where session | files may be stored. A default has been set for you but a different | location may be specified. This is only needed for file sessions. | */ 'files' => storage_path('framework/sessions'), /* |-------------------------------------------------------------------------- | Session Database Connection |-------------------------------------------------------------------------- | | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in your database configuration options. | */ 'connection' => null, /* |-------------------------------------------------------------------------- | Session Database Table |-------------------------------------------------------------------------- | | When using the "database" session driver, you may specify the table we | should use to manage the sessions. Of course, a sensible default is | provided for you; however, you are free to change this as needed. | */ 'table' => 'sessions', /* |-------------------------------------------------------------------------- | Session Sweeping Lottery |-------------------------------------------------------------------------- | | Some session drivers must manually sweep their storage location to get | rid of old sessions from storage. Here are the chances that it will | happen on a given request. By default, the odds are 2 out of 100. | */ 'lottery' => [2, 100], /* |-------------------------------------------------------------------------- | Session Cookie Name |-------------------------------------------------------------------------- | | Here you may change the name of the cookie used to identify a session | instance by ID. The name specified here will get used every time a | new session cookie is created by the framework for every driver. | */ 'cookie' => 'l5essential', /* |-------------------------------------------------------------------------- | Session Cookie Path |-------------------------------------------------------------------------- | | The session cookie path determines the path for which the cookie will | be regarded as available. Typically, this will be the root path of | your application but you are free to change this when necessary. | */ 'path' => '/', /* |-------------------------------------------------------------------------- | Session Cookie Domain |-------------------------------------------------------------------------- | | Here you may change the domain of the cookie used to identify a session | in your application. This will determine which domains the cookie is | available to in your application. A sensible default has been set. | */ 'domain' => null, /* |-------------------------------------------------------------------------- | HTTPS Only Cookies |-------------------------------------------------------------------------- | | By setting this option to true, session cookies will only be sent back | to the server if the browser has a HTTPS connection. This will keep | the cookie from being sent to you if it can not be done securely. | */ 'secure' => false, ]; ================================================ FILE: config/slack.php ================================================ '', /* |------------------------------------------------------------- | Default channel |------------------------------------------------------------- | | The default channel we should post to. The channel can either be a | channel like #general, a private #group, or a @username. Set to | null to use the default set on the Slack webhook | */ 'channel' => '#general', /* |------------------------------------------------------------- | Default username |------------------------------------------------------------- | | The default username we should post as. Set to null to use | the default set on the Slack webhook | */ 'username' => 'Robot', /* |------------------------------------------------------------- | Default icon |------------------------------------------------------------- | | The default icon to use. This can either be a URL to an image or Slack | emoji like :ghost: or :heart_eyes:. Set to null to use the default | set on the Slack webhook | */ 'icon' => null, /* |------------------------------------------------------------- | Link names |------------------------------------------------------------- | | Whether names like @regan should be converted into links | by Slack | */ 'link_names' => false, /* |------------------------------------------------------------- | Unfurl links |------------------------------------------------------------- | | Whether Slack should unfurl links to text-based content | */ 'unfurl_links' => false, /* |------------------------------------------------------------- | Unfurl media |------------------------------------------------------------- | | Whether Slack should unfurl links to media content such | as images and YouTube videos | */ 'unfurl_media' => true, /* |------------------------------------------------------------- | Markdown in message text |------------------------------------------------------------- | | Whether message text should be interpreted in Slack's Markdown-like | language. For formatting options, see Slack's help article: http://goo.gl/r4fsdO | */ 'allow_markdown' => true, /* |------------------------------------------------------------- | Markdown in attachments |------------------------------------------------------------- | | Which attachment fields should be interpreted in Slack's Markdown-like | language. By default, Slack assumes that no fields in an attachment | should be formatted as Markdown. | */ 'markdown_in_attachments' => [], // Allow Markdown in just the text and title fields // 'markdown_in_attachments' => ['text', 'title'] // Allow Markdown in all fields // 'markdown_in_attachments' => ['pretext', 'text', 'title', 'fields', 'fallback'] ]; ================================================ FILE: config/view.php ================================================ [ realpath(base_path('resources/views')), ], /* |-------------------------------------------------------------------------- | Compiled View Path |-------------------------------------------------------------------------- | | This option determines where all the compiled Blade templates will be | stored for your application. Typically, this is within the storage | directory. However, as usual, you are free to change this value. | */ 'compiled' => realpath(storage_path('framework/views')), ]; ================================================ FILE: database/.gitignore ================================================ *.sqlite ================================================ FILE: database/factories/ModelFactory.php ================================================ define(App\User::class, function(Faker\Generator $faker) { return [ 'name' => $faker->name, 'email' => $faker->safeEmail, 'password' => bcrypt('password'), 'remember_token' => str_random(60), ]; }); $factory->define(App\Article::class, function(Faker\Generator $faker) { $date = $faker->dateTimeThisMonth; return [ 'title' => $faker->sentence(), 'content' => $faker->paragraph(), 'created_at' => $date, 'updated_at' => $date, ]; }); $factory->define(App\Comment::class, function(Faker\Generator $faker) { return [ 'content' => $faker->paragraph, ]; }); $factory->define(App\Tag::class, function(Faker\Generator $faker) { $name = ucfirst($faker->optional(0.9, 'Laravel')->word); return [ 'name' => $name, 'slug' => str_slug($name), ]; }); $factory->define(App\Attachment::class, function(Faker\Generator $faker) { return [ 'name' => sprintf("%s.%s", str_random(), $faker->randomElement(['png', 'jpg'])), ]; }); ================================================ FILE: database/migrations/.gitkeep ================================================ ================================================ FILE: database/migrations/2014_10_12_000000_create_users_table.php ================================================ increments('id'); $table->string('name'); $table->string('email')->unique(); $table->string('password', 60)->nullable(); $table->rememberToken(); $table->timestamp('last_login')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('users'); } } ================================================ FILE: database/migrations/2014_10_12_100000_create_password_resets_table.php ================================================ string('email')->index(); $table->string('token')->index(); $table->timestamp('created_at'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('password_resets'); } } ================================================ FILE: database/migrations/2015_01_15_105324_create_roles_table.php ================================================ increments('id')->unsigned(); $table->string('name'); $table->string('slug')->unique(); $table->string('description')->nullable(); $table->integer('level')->default(1); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('roles'); } } ================================================ FILE: database/migrations/2015_01_15_114412_create_role_user_table.php ================================================ increments('id')->unsigned(); $table->integer('role_id')->unsigned()->index(); $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); $table->integer('user_id')->unsigned()->index(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('role_user'); } } ================================================ FILE: database/migrations/2015_01_26_115212_create_permissions_table.php ================================================ increments('id')->unsigned(); $table->string('name'); $table->string('slug')->unique(); $table->string('description')->nullable(); $table->string('model')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('permissions'); } } ================================================ FILE: database/migrations/2015_01_26_115523_create_permission_role_table.php ================================================ increments('id')->unsigned(); $table->integer('permission_id')->unsigned()->index(); $table->foreign('permission_id')->references('id')->on('permissions')->onDelete('cascade'); $table->integer('role_id')->unsigned()->index(); $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('permission_role'); } } ================================================ FILE: database/migrations/2015_02_09_132439_create_permission_user_table.php ================================================ increments('id')->unsigned(); $table->integer('permission_id')->unsigned()->index(); $table->foreign('permission_id')->references('id')->on('permissions')->onDelete('cascade'); $table->integer('user_id')->unsigned()->index(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('permission_user'); } } ================================================ FILE: database/migrations/2015_11_20_062500_create_comments_table.php ================================================ increments('id'); $table->integer('author_id')->unsigned()->index(); $table->string('commentable_type'); $table->integer('commentable_id')->unsigned(); $table->integer('parent_id')->unsigned()->nullable(); $table->string('title')->nullable(); $table->text('content'); $table->timestamps(); $table->softDeletes(); $table->foreign('author_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('parent_id')->references('id')->on('comments'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('comments'); } } ================================================ FILE: database/migrations/2015_11_20_062513_create_articles_table.php ================================================ increments('id'); $table->integer('author_id')->unsigned()->index(); $table->string('title'); $table->text('content'); $table->integer('solution_id')->unsigned()->nullable(); $table->boolean('notification')->default(1); $table->tinyInteger('view_count')->default(0); $table->boolean('pin')->default(0); $table->timestamps(); $table->softDeletes(); $table->foreign('author_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('solution_id')->references('id')->on('comments'); }); if (config('database.default') != 'sqlite') { DB::statement('ALTER TABLE articles ADD FULLTEXT search(title, content)'); } } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('articles'); } } ================================================ FILE: database/migrations/2015_11_20_062601_create_tags_table.php ================================================ increments('id'); $table->string('name'); $table->string('slug')->index(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('tags'); } } ================================================ FILE: database/migrations/2015_11_20_062613_create_attachments_table.php ================================================ increments('id'); $table->integer('article_id')->unsigned()->index(); $table->string('name'); $table->timestamps(); // $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('attachments'); } } ================================================ FILE: database/migrations/2015_11_20_062846_create_article_tag_table.php ================================================ increments('id'); $table->integer('article_id')->unsigned(); $table->integer('tag_id')->unsigned(); $table->timestamps(); $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade'); $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('article_tag'); } } ================================================ FILE: database/migrations/2015_12_09_165930_create_lessons_table.php ================================================ increments('id'); $table->integer('author_id')->unsigned(); $table->string('name'); $table->text('content'); $table->timestamps(); $table->foreign('author_id')->references('id')->on('users'); }); if (config('database.default') != 'sqlite') { DB::statement('ALTER TABLE lessons ADD FULLTEXT docs(name, content)'); } } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('lessons'); } } ================================================ FILE: database/migrations/2015_12_10_151357_create_votes_table.php ================================================ increments('id'); $table->integer('user_id')->unsigned(); $table->integer('comment_id')->unsigned(); $table->tinyInteger('up')->nullable(); $table->tinyInteger('down')->nullable(); $table->timestamp('voted_at'); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('comment_id')->references('id')->on('comments')->onDelete('cascade'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::drop('votes'); } } ================================================ FILE: database/seeds/.gitkeep ================================================ ================================================ FILE: database/seeds/DatabaseSeeder.php ================================================ create([ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => bcrypt('password') ]); factory(App\User::class, 9)->create(); $this->command->info('users table seeded'); /** * Seeding roles table */ Bican\Roles\Models\Role::truncate(); DB::table('role_user')->truncate(); $adminRole = Bican\Roles\Models\Role::create([ 'name' => 'Admin', 'slug' => 'admin' ]); $memberRole = Bican\Roles\Models\Role::create([ 'name' => 'Member', 'slug' => 'member' ]); App\User::where('email', '!=', 'john@example.com')->get()->map(function($user) use($memberRole) { $user->attachRole($memberRole); }); App\User::whereEmail('john@example.com')->get()->map(function($user) use($adminRole){ $user->attachRole($adminRole); }); $this->command->info('roles table seeded'); /* * Seeding articles table */ App\Article::truncate(); $users = App\User::all(); $users->each(function($user) use($faker) { $user->articles()->save( factory(App\Article::class)->make() ); $user->articles()->save( factory(App\Article::class)->make() ); }); $this->command->info('articles table seeded'); /** * Seeding comments table */ App\Comment::truncate(); $articles = App\Article::all(); $articles->each(function($article) use($faker, $users) { $article->comments()->save( factory(App\Comment::class)->make([ 'author_id' => $faker->randomElement($users->lists('id')->toArray()) ]) ); }); $this->command->info('comments table seeded'); /* * Seeding tags table */ App\Tag::truncate(); DB::table('article_tag')->truncate(); $rawTags = config('project.tags'); foreach($rawTags as $tag) { App\Tag::create([ 'name' => $tag, 'slug' => str_slug($tag) ]); } $tags = App\Tag::all(); foreach($articles as $article) { $article->tags()->attach( $faker->randomElements( $tags->lists('id')->toArray(), $faker->randomElement([1,2,3]) ) ); } $this->command->info('tags table seeded'); /* * Seeding attachments table */ App\Attachment::truncate(); if (! File::isDirectory(attachment_path())) { File::deleteDirectory(attachment_path(), true); } $articles->each(function($article) use($faker) { $article->attachments()->save( factory(App\Attachment::class)->make() ); }); $files = App\Attachment::lists('name'); if (! File::isDirectory(attachment_path())) { File::makeDirectory(attachment_path(), 777, true); } foreach($files as $file) { File::put(attachment_path($file), ''); } $this->command->info('attachments table seeded'); /** * Close seeding */ // Not required for Laravel 5.2 // @see https://laravel.com/docs/5.2/upgrade#upgrade-5.2.0 // Model::reguard(); if (config('database.default') != 'sqlite') { DB::statement('SET FOREIGN_KEY_CHECKS=1'); } } } ================================================ FILE: gulpfile.js ================================================ var elixir = require('laravel-elixir'); elixir(function (mix) { mix.sass('app.scss', 'resources/assets/css') .styles([ 'app.css', '../vendor/dropzone/dist/dropzone.css', '../vendor/earthsong.css', ], 'public/css/app.css'); mix.scripts([ '../vendor/jquery/dist/jquery.js', '../vendor/bootstrap-sass/assets/javascripts/bootstrap.js', '../vendor/fastclick/lib/fastclick.js', '../vendor/select2/dist/js/select2.js', '../vendor/dropzone/dist/dropzone.js', '../vendor/tabby/jquery.textarea.js', '../vendor/autosize/dist/autosize.js', '../vendor/highlightjs/highlight.pack.js', '../vendor/marked/lib/marked.js', 'app.js' ], 'public/js/app.js'); mix.version([ 'css/app.css', 'js/app.js' ]); /* font files are static, so doesn't need to be copied every gulp run. //mix.copy("resources/assets/vendor/font-awesome/fonts", "public/build/fonts"); /* To activate Browser Sync, uncomment and run $ gulp watch */ //mix.browserSync(); }); ================================================ FILE: lessons/01-welcome.md ================================================ --- extends: _layouts.master section: content current_index: 0 --- # 1강 - 처음 만나는 라라벨 라라벨은 php 언어로 짜여진 MVC 아키텍처를 지원하는 웹 프레임웍이다. 루비 언어에 레일즈, 파이썬 언어에 장고와 대칭되는 존재라고 보면 된다. [SitePoint의 2015년 설문조사](http://www.sitepoint.com/best-php-framework-2015-sitepoint-survey-results/)에 따르면, 라라벨은 **(국외에서)** 현재 가장 인기 있는 php 프레임웍으로 알려져 있다. 국산 CMS 중 사용자/사이트 수 측면에서 1위 CMS인 [XE](https://www.xpressengine.com/) 에서도 차기 버전인 XE3 는 라라벨로 전환한다고 발표한 바 있다. ## 인기의 비결은? - 단순하고(== 쉽고) 우아한 문법 - 복잡한 것들은 프레임웍 안에서 처리 - 강력한 확장 기능들 - PSR(Php Standards Recommendations) 적용 - 모던 개발 방법론 적용 - RAD RAD RAD (Rapid Application Development) **`참고`** 라라벨은 다른 php 프레임웍 대비 "무겁다", "느리다"고 알려져 있다. 라라벨의 철학은 "개발 생산성"이라는 것을 기억하자. 우리가 사는 2015년은, 개발자 인건비에 비하면 큰 메모리에 SSD로 서버 성능을 높이는 비용은 너무나도 싸다. 가령, DigitalOcean의 경우 최고 사양인 8GB메모리, 4Core, 80GB HDD의 IaaS 사용 비용이 월 $80이다. 사용자가 수 억에 달해 스케일아웃으로도 한계에 달해 정말 성능 최적화를 해야할 시점이 된다면 정말 행복한 고민일 것이다. 그때는 훌륭한 개발자들을 뽑아서 하면 된다. 페이스북을 보라~ HHVM 이란 php 엔진도 지들이 직접 만들어 쓰지 않는가? 웬만한 규모에서는 시스템엔지니어 고용 대신 AWS 쓰고, 성능을 최적화하는 마이크로 레벨의 개발보다는 개발 시간을 단축할 수 있는 도구를 사용하는 것이 훨씬 더 현명한 선택일 것이다. ## 내장 기능 (Free) 이 강좌를 통해 소개되는 기본적인 기능외에도 라라벨 5 에는 많은 기능이 포함되어 있다. - 웹 서비스를 위해 필요한 Cache, Queue, Mail, ... - [Service Container](http://laravel.com/docs/container)를 이용한 의존성 자동 주입 - [Cron 자동화](http://laravel.com/docs/scheduling) - [Elixir](http://laravel.com/docs/elixir)를 이용한 CSS/Sass/Less, JS/Coffee 등 Frontend 워크플로우 자동화 - ... ## 확장 기능 (Free) - [Homestead](https://github.com/laravel/homestead) 로 개발 환경을 표준화할 수 있다. - [Socialite](https://github.com/laravel/socialite)로 소셜 인증을 쉽게 할 수 있다. [Socialite Provider의 목록](http://socialiteproviders.github.io/)을 확인해 보자. - [Cashier](https://github.com/laravel/cashier)로 결제 기능을 쉽게 달 수 있다. - [Envoy](https://github.com/laravel/envoy) 로 SSH 원격 작업을 자동화할 수 있다. - [Laravel Collective](http://laravelcollective.com/) 라라벨 구버전에 있었으나 빠진 기능들의 모음이다. ## 마이크로 프레임웍 - 루멘 (Free) 라라벨을 쓰는 것이 너무 오버라고 생각되는, 아주 간단한 서비스를 개발하려 할 땐, - [Lumen](http://lumen.laravel.com/) ## 확장 서비스 ($) - [Forge](https://forge.laravel.com/)를 이용하여 서버 프로비저닝/서버 관리/코드 배포 등을 자동화할 수 있다. ($10/월, 서버 댓수 제한 없음) - [Envoyer](https://envoyer.io/)를 이용하여 무중단 코드 배포를 할 수 있다. ($10/월, 10 프로젝트) ## 커뮤니티 - [라라벨 뉴스](https://laravel-news.com/) - 프레임웍 코어 멤버 중의 한명인 Eric Barnes 가 운영하는 뉴스 블로그. 라라벨 개발자라면, 또는 되려면 뉴스레터에 꼭 가입하라. - [라라 캐스트](https://laracasts.com/) - 역시 코어 멤버 중의 한명인 Jeffrey Way가 운영하는 동영상 강의 서비스. 매주 2~3개의 강의가 올라오며, 기존에 작성된 거의 400편에 가까운 동영상 강의를 볼 수 있다 ($10/월). 라라벨 사용자가 몰려 있는 서비스로서, 포럼도 같이 운영 중이며 포럼 활동도 아주 활발하다. - [LARAVEL.IO](http://laravel.io/forum) - 라라 캐스트 전에 가장 활발한 활동을 하던 포럼이다. - [Codecourse](https://www.youtube.com/user/phpacademy) - phpacademy 란 채널이 최근이 이름이 바뀌었다. 무료 동영상 강의를 제공하고 있다. - 그 외에도 구글링 해보면 라라벨 관련 *영어* 자료는 넘쳐 난다. ## 한국어 리소스 - [한국어 매뉴얼 (번역 by XE팀)](http://xpressengine.github.io/laravel-korean-docs/) - [라라벨 영상 강좌 (by XE팀)](https://www.xpressengine.com/learn/23061328) - [한국어 책 (by 정광섭님)](https://www.lesstif.com/pages/viewpage.action?pageId=28606603) --- 이제 라라벨 5 로 여행을 떠나 보자. - [목록으로 돌아가기](../readme.md) - [2강 - 라라벨 5 설치하기](02-hello-laravel.md) ================================================ FILE: lessons/02-hello-laravel.md ================================================ --- extends: _layouts.master section: content current_index: 1 --- # 2강 - 라라벨 5 설치하기 (on Mac) ## 윈도우즈 사용자라면 [여기를 참고](02-install-on-windows.md)하자. 장기적으로 보고 Mac 이나 Linux 로 전환할 것을 권장한다. 윈도우즈는 코맨드 프롬프트(콘솔)에서 명령어 사용이 불편해서 생산성이 떨어진다. ## 터미널에 익숙해 지자. 기본 내장 터미널 (=='콘솔') 소프트웨어를 사용하지 말고, [iTerm2](https://www.iterm2.com/)(Free) 와 [Oh My Zshell](https://github.com/robbyrussell/oh-my-zsh)(Free) 을 쓸 것을 권장한다. iTerm2 는 Mac 내장 콘솔의 대체 앱이며, Oh My Zshell 은 콘솔의 기능을 더 편리하게 해 주는 플러그인이라 생각하면 된다. ```bash $ sh -c "$(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" ``` Mac 용 패키지 매니저인 [Homebrew](http://brew.sh/)(Free) 도 반드시 설치해 놓자! `Homebrew` 는 Mac 용 `apt-get` 이라 이해하면 된다. ```bash $ ruby --version # ruby 2.x $ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" ``` ## 개발 환경 셋업 ### [이 코스에서 사용] 로컬 개발 환경 Mac 사용자라면 2가지 옵션이 있다. 로컬 Mac 을 개발 머신으로 쓰거나, 다음 절에 설명하는 Homestead 를 쓰는 방법이다. 이 코스에서는 **로컬 Mac을 개발환경으로 쓰는 것을 가정하고 설명**한다. 로컬 Mac에 php, mySql 이 설치되어 있지 않다면 Homebrew 를 이용해 설치하자. ```bash $ brew search php56 # Homebrew 설정에 따라 php56 또는 homebrew/php/php56이 출력될 것이다. $ brew install homebrew/php/php56 $ brew search mysql5 # 역시 Homebrew 설정에 결과는 다를 수 있다. 가장 높은 버전을 설치하자. $ brew install homebrew/versions/mysql55 $ /usr/local/bin/mysql.server start ``` 터미널을 반드시 한번 껐다가 켜자. 설치되는 동안 사용자의 콘솔 프로파일에 경로를 업데이트했을텐데, 그 경로를 다시 로드하기 위해서다. ```bash $ php --version # 5.6.xx $ mysql --version # 5.5.xx ``` **`참고`** Mac 부트시 자동으로 mySql 서버를 기동시키려면, mySql 설치 코맨드가 끝난 후 출력된 내용을 유심히 살펴보기 바란다. `mv /usr/local/opt/mysql/homebrew.mxcl.mysql.plist ~/Library/LaunchAgents/homebrew.mxcl.mysql.plist`와 같은 명령이 있을텐데, 환경마다 다를 수 있을니 필자가 쓴 코맨드가 아니라, 실제 콘솔에 있는 코맨드를 복사해서 콘솔에 붙여 넣고 실행한다. ### **[OPTIONAL]** 공짜로 쓰는 개발 서버 "Homestead" 개발팀 구성원들이 동일한 개발 환경을 가지기 위해서, 또는 Production 과 유사한 환경에서 개발하기 위해서 Homestead 사용을 권장한다. Homestead 는 위에서 언급한 필요 확장 모듈이 기본 설치되어 있다. 설정법은 꽤나 까다로우니 [Homestead 설치 (on Mac)](02-install-homestead-osx.md) 를 참고하자. ## 라라벨이 동작하기 위한 PHP 버전 및 필요 모듈 조건 확인 라라벨을 설치하려는 개발환경 또는 서버가 아래 필요사항을 충족하는 지 확인한다. - php 5.5.9 이상 - php Extensions - OpenSSL - PDO - Mbstring - Tokenizer ```bash $ php --version # PHP 5.6.xx $ php -m | grep 'openssl\|pdo\|mbstring\|tokenizer' ``` Homebrew 를 통해 설치했다면 기본적으로 모든 모듈이 설치되어 있을 것이다. 하나라도 빠진게 있다면 구글링해서 설치하자~ ## 이제 라라벨을 설치해 보자. 라라벨 인스톨러를 사용할 것을 권장한다. 왜냐하면 Composer 를 이용해 설치하는 것 보다 훨~씬 빠르니까... 먼저, Composer 가 필요하다. 왜냐하면, 라라벨 인스톨러가 Composer 를 통해 배포되기 때문이다. ```bash $ curl -sS https://getcomposer.org/installer | php $ mv composer.phar /usr/local/bin/composer $ composer --version # Composer version 1.xx ``` 이제 Composer 를 이용해서 라라벨 인스톨러를 설치한다. ```bash $ composer global require "laravel/installer=~1.1" ``` 끝이 아니다. `laravel`과 `homestead` 코맨드를 어디서든 접근할 수 있게 경로 설정하자. ```bash # 터미널에 익숙치 않은 분이 많아 자세히 쓴다. # 자신이 사용하는 콘솔에 따라 프로파일 이름이 다를 수 있다. ~/.profile, ~/.bash_profile, ~/.zshrc, ... # On My Zshell을 설치했다면 ~/.zshrc 이다. $ nano ~/.zshrc # 아래와 같이 써진 줄을 찾아 맨 끝에 :$HOME/.composer/vendor/bin 을 추가하자. export PATH="$PATH:$HOME/.composer/vendor/bin" # 쇠뿔도 단김에 앞으로 자주 쓰게 될 artisan 코맨드의 별명을 등록해 놓자. # 앞으로 자주 쓰게될 php artisan 대신 artisan 만 치면 된다. # 열린 파일 맨 끝에 써 넣는다. alias artisan="php artisan" # ctrl + x -> Y -> enter 순으로 눌러 수정 내용을 저장한다. # 수정된 내용이 반영될 수 있도록 콘솔을 다시 시작하거나, 아래 코맨드를 실행한다. $ source ~/.zshrc # laravel 코맨드의 경로가 잘 설정되었는지 확인해 보자. $ laravel --version # Laravel Installer version 1.x ``` 휴~, 이제 설치를 위한 준비가 완료되었다. 라라벨 인스톨러로 라라벨 5를 설치하자. ```bash $ laravel new myProject $ cd myProject $ php artisan --version # Laravel Framework version 5.x $ chmod -R 777 storage bootstrap/cache ``` 서버를 부트업하고, 라라벨을 시작해 보자! ```bash # 로컬 서버를 부트한다. $ php artisan serve # 종료하려면 ctrl+c # Laravel development server started on http://localhost:8000/ ``` 브라우저에서 `http://localhost:8000` 페이지를 방문해서 'Laravel 5' 란 글씨가 써진 화면이 보인다면, 성공적으로 설치한 것이다. ![](./images/02-hello-laravel-img-02.png) **`참고`** `artisan` 은 라라벨의 코맨드 라인 툴이다. `$ php artisan` 을 실행한 후, 설명을 쭈욱~ 한번 살펴보자. 개발 중에 코드에디터와 콘솔을 오가면서, `artisan` 코맨드를 많이 사용하게 될 것이다. ## 라라벨의 폴더 구조를 살펴 보자. 처음 설치하면 딱 아래와 같은 형태이다. 지금은 눈에 안들어 오니, 그냥 휘~익 훑어 보자. 코스를 진행하다 보면 저절로 익히게 된다. ``` . ├── .env # 글로벌 설정 중 민감한 값, dev/production 등 앱 실행환경에 따라 변경되어야 하는 값을 써 놓는 곳 ├── app │   ├── Console │   │   ├── Commands # 콘솔 코맨드 하우징 │   │   └── Kernel.php # 콘솔 코맨드, 크론 스케쥴 등록 │   ├── Events # 이벤트 클래스 하우징 │   ├── Exceptions # Exception 하우징 │   │   └── Handler.php # 글로벌 Exception 처리 코드 │   ├── Listeners # 이벤트 핸들러 │   ├── Jobs │   ├── Policies │   ├── Http # Http 요청 처리 클래스들의 하우징 │   │   ├── Controllers # Http Controller │   │   ├── Kernel.php # Http 및 Route 미들웨어 등록 │   │   ├── Middleware # Http 미들웨어 하우징 │   │   ├── Requests # Http 폼 요청 미들웨어 하우징 │   │   └── routes.php # Http 요청 Url을 Controller에 맵핑시키는 규칙을 써 놓은 테이블 │   └── Providers # 서비스 공급자 하우징 (config/app.php에서 바인딩 됨) │      ├── AppServiceProvider.php │      ├── AuthServiceProvider.php │      ├── EventServiceProvider.php # 이벤트 리스너, 구독 바인딩 │      └── RouteServiceProvider.php # 라우팅 바인딩 (글로벌 라우팅 파라미터 패턴 등이 등록되어 있음) ├── composer.json # 이 프로젝트의 Composer 레지스트리, Autoload 규칙 등이 담겨 있다. (c.f. Node의 package.json) ├── config # database, queue, mail 등 글로벌 설정 하우징 ├── database │   ├── migrations # 데이터베이스 스키마 │   └── seeds # 생성된 테이블에 Dummy 데이터를 삽입하는 클래스들 (개발 목적) ├── gulpfile.js # Elixir (프론트엔드 빌드 자동화) 스크립트 ├── public # 웹 서버에 의해 지정된 Document Root ├── resources │   ├── assets # JavaScript, CSS 하우징 │   ├── lang # 다국어 지원을 위한 언어 레지스트리 하우징 │   └── views # 뷰 파일 하우징 ├── storage # Laravel5 파일 저장소 └── vendor # composer.json의 저장소 ``` ## 라라벨의 프로젝트 구조 아래 그림을 보자. 방금 설치한 상태가 그림 왼쪽에 빨간색으로 표시된 Laravel (`laravel/laravel`) 과 Framework (`laravel/framework`) 가 설치된 상태이다. 별도로 분리해 놓은 이유는 Framework 요소가 Laravel 이 아닌, 가령 [Lumen](http://lumen.laravel.com/) 처럼 다른 프로젝트에서도 사용할 수 있도록 하기 위해서이다. 라라벨이 제공하는 문법과 API 들을 이용해서 User Code (`appkr/l5essential`) 라고 표시된, 우리만의 서비스를 만들게 된다. 이 과정에서 라라벨에서 제공하는 기본 기능외에 외부의 패키지들, User-pulled 3rd Party Packages 라 표시된 부분들도 가져와서 사용하게 된다. ![](./images/02-hello-laravel-img-03.png) ## 라라벨의 동작 시퀀스 역시 마찬가지다. 지금은 몰라도 된다. 나중에 한번 돌아와서 다시 보게 된다면, 아~ 하고 이해될 것이다. ![](./images/02-hello-laravel-img-01.png) --- - [목록으로 돌아가기](../readme.md) - [1강 - 처음 만나는 라라벨](01-welcome.md) - [3강 - 글로벌 설정 살펴보기](03-configuration.md) ================================================ FILE: lessons/02-install-homestead-osx.md ================================================ --- extends: _layouts.master section: content current_index: 57 --- # Homestead 설치 (on Mac) ## 사전 요구 사항 [VirtualBox](https://www.virtualbox.org/wiki/Downloads) 와 [Vagrant](http://www.vagrantup.com/downloads.html) 설치가 필요하다. 인스톨러 화면에서 "Next" 만 계속 눌러서 쉽게 설치할 수 있다. ## Vagrant Box 설치 라라벨 커뮤니티에서 미리 준비해서 [Vagrant Box Registry](https://atlas.hashicorp.com/boxes/search) 에 배포해 놓은 `laravel/homestead` Vagrant Box (==Virtual Machine Image) 를 다운로드 하는 과정이다. 이 강좌를 쓰는 시점에 `laravel/homestead` Vagrant Box 의 최신 버전은 PHP7 이 기본 포함되어 있는 0.4.0 이다. 여기서는 PHP5 버전을 쓸 것이므로, `--box-version 0.3.3` 옵션을 추가해 주었다. ```bash $ vagrant box add laravel/homestead --box-version 0.3.3 # 설치에 실패했다면, 기존 다운로드 찌거기를 지우고 다시 다운로드 받기 위해 --clean 옵션 스위치를 붙여야 한다. # 기존에 설치한 laravel/homestead Box 을 덮어쓰고 강제로 다시 설치하려면 --force 옵션 스위치를 붙인다. ``` 설치 과정에 아래 처럼 Virtual Machine Provider 를 선택하는 화면이 나온다. `2) vmware` 는 [Vagrant 용 Plugin](http://www.vagrantup.com/vmware) 을 별도로 사서 설치해야 한다는 점을 알고 있자. 이 강좌를 쓰는 현재 Vmware Vagrant Plugin 의 가격은 $79 이다. 무료로 쓸 수 있는 `1) virtualbox` 를 선택했다. OS 에 따라 수 GB 용량을 다운로드 받아야 하므로 꽤 오랜 시간이 걸린다. ```bash This box can work with multiple providers! The providers that it can work with are listed below. Please review the list and choose the provider you will be working with. 1) virtualbox 2) vmware_desktop Enter your choice: 1 ``` ## Homestead CLI 설치 [Homestead CLI](https://github.com/laravel/homestead) 는 `vagrant` CLI 의 Wrapper 이다. `vagrant` 대신 `homestead` 코맨드를 이용할 수 있다. ```bash $ composer global require "laravel/homestead:2.*" ``` `homestead` CLI 는 '~/.composer' 디렉토리에 설치된다. 경로 지정없이 `homestead` 명령을 사용하려면, 아래 처럼 경로를 추가해 주어야 한다. ```bash # 사용하는 Shell 에 따라 Profile 파일 이름은 다를 수 있다. # 필자는 Zshell 을 쓰므로, .zshrc 이다. e.g. .profile, .bashrc $ nano ~/.zshrc # composer global 로 설치한 패키지들의 실행파일을 경로에 넣어 준다. # 이 과정이 없다면 $ ~/.composer/vendor/bin/homestead 와 같이 전체 경로를 써주어야 한다. export PATH="$PATH:$HOME/.composer/vendor/bin" # 수정했다면 ctrl + X, "Y" 를 눌러 변경 내용을 저장하고, 엔터를 한번 더 눌러 기존 파일을 덮어 쓴다. # 그리고, 수정 내용을 현재 콘솔에 적용해 준다. 콘솔을 껐다가 다시 실행해도 된다. $ source ~/.zshrc ``` 최신 버전이 `3.*` 이지만, 필자는 굳이 구 버전인 `2.*` 를 설치했다. 새 버전은 `laravel/homestead` Vagrant Box v0.4.0 과 궁합이 맞도록 구현 되어 있을 뿐만아니라, 아래에 나온 편리한 코맨드들도 모두 제거되었기 때문이다. ```bash $ homestead Laravel Homestead version 2.1.8 Usage: command [options] [arguments] Options: # ... Available commands: destroy Destroy the Homestead machine edit Edit a Homestead file halt Halt the Homestead machine help Displays help for a command init Create a stub Homestead.yaml file list Lists commands make Install Homestead into the current project provision Re-provisions the Homestead machine resume Resume the suspended Homestead machine run Run commands through the Homestead machine via SSH ssh Login to the Homestead machine via SSH ssh-config Outputs OpenSSH valid configuration to connect to Homestead status Get the status of the Homestead machine suspend Suspend the Homestead machine up Start the Homestead machine update Update the Homestead machine image ``` `make` 란 명령은 꽤 최근에 추가된 것인데, 각 프로젝트마다 별도의 Homestead 설정을 만들 수 있는 명령이다. Docker 의 Container 개념과 유사하다고 볼 수 있다. 복수의 프로젝트를 개발 중이고, 프로젝트간에 서버 환경이 굉장히 많이 다르다면, 사용하면 좋을 것 같다. ## Homestead 설정 `laravel/homestead` Vagrant VM 을 올바르게 셋팅하기 위한 설정 파일은 '~/.homestead' 디렉토리에 위치해야 한다. 아래 명령으로 이 설정 파일을 초기화하자. ```bash $ homestead init ``` Homestead 설정을 우리 프로젝트에 맞게 수정하자. ```bash $ homestead edit # 로컬에 Sublime Text 가 설치되어 있지 않다면.. # ~/.homestead/Homestead.yaml 을 편집기로 직접 열면 된다. ``` ```bash # ~/.homestead/Homestead.yaml ip: "192.168.10.10" # homestead VM 이 사용할 ip 주소 memory: 2048 cpus: 1 provider: virtualbox # Virtual Machine Provider # SSH 로그인에 사용할 public key. 이 키 값은 homestead VM 의 # /home/vagrant/.ssh/authorized_keys 에 자동으로 추가된다. authorize: ~/.ssh/id_rsa.pub keys: - ~/.ssh/id_rsa # SSH 로그인에 사용할 private key # 로컬과 VM 간에 공유할 폴더를 설정한다. # 이 강좌용 라라벨은 ~/workspace/myProject 에 위치한다고 가정한다. folders: - map: ~/workspace # 로컬 디렉토리 경로 to: /home/vagrant/Code # VM 의 디렉토리 경로 # 도메인 이름 (hostname) 과 VM 에 설치된 웹 서버의 Document Root 를 설정한다. sites: - map: myproject.dev # 도메인 이름 to: /home/vagrant/Code/myProject/public # 웹 서버의 Document Root # 사이트를 추가하려면.. 아래 처럼 추가 사이트를 정의한 다음 # VM 콘솔에서 $ homestead provision 명령을 실행한다. - map: example.dev to: /home/vagrant/Code/example/public # 서버 프로비저닝 과정에서 아래에 정의한 DB 를 만들어 준다. databases: - myProject ``` ## Host 파일 설정 Homestead 설정에서 myproject.dev 란 도메인을 이용했다. 이런 도메인은 존재하지 않는다. 운영체제의 Host 파일을 수정할 것이다. 운영체제에 포함된 'hosts' 파일은 DNS 로 myproject.dev 에 대한 ip 주소 Resolution 요청이 나가기 전에 요청을 낚아 채서, 'hosts' 파일 안에서 찾는다. 사용자가 요청한 도메인에 해당하는 레코드가 있으면 지정된 ip 주소로 이동할 것이다. ```bash $ sudo nano /etc/hosts ``` ```bash # /etc/hosts 192.168.10.10 myproject.dev ``` ## Homestead 실행 실행해 보자. 처음 실행할 때는 시간이 좀 걸리는데, 이유는 앞에서 Homestead.yaml 에 설정한, ip 주소, public key 복사, 공유 폴더 설정 등을 하기 때문이다. ```bash $ homestead up # homestead VM 을 중지시킬 때는 $ homestead halt # 완전히 끌 때는 $ homestead suspend ``` VM 에 로그인하고 Homestead.yaml 설정이 잘 먹었나 확인해 보자. ```bash $ homestead ssh # Welcome to Ubuntu 14.04.3 LTS (GNU/Linux 3.19.0-25-generic x86_64) vagrant@homestead:~$ ls ~/Code # ... myProject ... vagrant@homestead:~$ ifconfig | grep 192.168.10.10 # inet addr:192.168.10.10 Bcast:192.168.10.255 Mask:255.255.255.0 vagrant@homestead:~$ cat ~/.ssh/authorized_keys # ssh-rsa AAAAB3NzaC1...D3XDfv juwonkim@me.com ``` **`참고`** ssh 를 이용해 직접 접속하려면 `$ ssh vagrant@myproject.dev -D 2222`. ## 데이터베이스 접속 Host `127.0.0.1`, Port `33060`, Username `homestead`, Password `secret` 로 접속한다. PostgresSQL 의 경우 Port `54320` 으로 접속한다. ![](./images/02-install-homestead-osx-img-01.png) ## 웹 서버 접속 Homestead 에는 Nginx 가 기본으로 탑재되어 있고, Homestead.yaml 의 sites 섹션에서 설정한대로 이미 서비스가 돌고 있는 상태이다. 브라우저에서 'http://myproject.dev' 로 접속해 보자. 테스트용으로 쓸 수 있는 self-signed 인증서가 설치되어 있기 때문에 'https://myproject.dev' 도 사용할 수 있다. --- - [목록으로 돌아가기](../readme.md) ================================================ FILE: lessons/02-install-homestead-windows.md ================================================ --- extends: _layouts.master section: content current_index: 58 --- # Homestead 설치 (on Windows) ## 사전 요구 사항 [VirtualBox](https://www.virtualbox.org/wiki/Downloads) 와 [Vagrant](http://www.vagrantup.com/downloads.html) 설치가 필요하다. 인스톨러 화면에서 "Next" 만 계속 눌러서 쉽게 설치할 수 있다. ## Vagrant Box 설치 라라벨 커뮤니티에서 미리 준비해서 [Vagrant Box Registry](https://atlas.hashicorp.com/boxes/search) 에 배포해 놓은 `laravel/homestead` Vagrant Box (==Virtual Machine Image) 를 다운로드 하는 과정이다. 이 강좌를 쓰는 시점에 `laravel/homestead` Vagrant Box 의 최신 버전은 PHP7 이 기본 포함되어 있는 0.4.0 이다. ```bash $ vagrant box add laravel/homestead # 설치에 실패했다면, 기존 다운로드 찌거기를 지우고 다시 다운로드 받기 위해 --clean 옵션 스위치를 붙여야 한다. # 기존에 설치한 laravel/homestead Box 을 덮어쓰고 강제로 다시 설치하려면 --force 옵션 스위치를 붙인다. ``` 설치 과정에 아래 처럼 Virtual Machine Provider 를 선택하는 화면이 나온다. `2) vmware` 는 [Vagrant 용 Plugin](http://www.vagrantup.com/vmware) 을 별도로 사서 설치해야 한다는 점을 알고 있자. 이 강좌를 쓰는 현재 Vmware Vagrant Plugin 의 가격은 $79 이다. 무료로 쓸 수 있는 `1) virtualbox` 를 선택했다. OS 에 따라 수 GB 용량을 다운로드 받아야 하므로 꽤 오랜 시간이 걸린다. ```bash This box can work with multiple providers! The providers that it can work with are listed below. Please review the list and choose the provider you will be working with. 1) virtualbox 2) vmware_desktop Enter your choice: 1 ``` ## Homestead 프로젝트 설치 `git` 명령을 쓸 수 없는 경우, [https://github.com/laravel/homestead](https://github.com/laravel/homestead) 를 방문해서 zip 파일을 다운로드 한 후, 적절한 위치에 압축을 해제한다. ```bash # Git Bash $ cd ~ && git clone https://github.com/laravel/homestead.git Homestead ``` ## Homestead 설정 `laravel/homestead` Vagrant VM 을 올바르게 셋팅하기 위한 설정 파일은 '~/.homestead' 디렉토리에 위치해야 한다. 아래 명령으로 이 설정 파일을 초기화하자. ```bash # Git Bash $ cd ~/Homestead && bash init.sh # Windows Command Prompt \> cd %HOMEPATH%\Homestead \> init ``` Homestead 설정을 우리 프로젝트에 맞게 수정하자. 코드 에디터 또는 텍스트 편집기로 %HOMEPATH%\.homestead\Homestead.yaml 파일을 연다. ```bash # /c/Users/{username}/.homestead/Homestead.yaml # Or %HOMEPATH%\.homestead\Homestead.yaml ip: "192.168.10.10" # homestead VM 이 사용할 ip 주소 memory: 2048 cpus: 1 provider: virtualbox # Virtual Machine Provider # SSH 로그인에 사용할 public key. 이 키 값은 homestead VM 의 # /home/vagrant/.ssh/authorized_keys 에 자동으로 추가된다. authorize: ~/.ssh/id_rsa.pub keys: - ~/.ssh/id_rsa # SSH 로그인에 사용할 private key # 로컬과 VM 간에 공유할 폴더를 설정한다. # 이 강좌용 라라벨은 ~/myProject 에 위치한다고 가정한다. folders: - map: ~/myProject # 로컬 디렉토리 경로 to: /home/vagrant/myProject # VM 의 디렉토리 경로 # 도메인 이름 (hostname) 과 VM 에 설치된 웹 서버의 Document Root 를 설정한다. sites: - map: myproject.dev # 도메인 이름 to: /home/vagrant/myProject/public # 웹 서버의 Document Root # 사이트를 추가하려면.. 아래 처럼 추가 사이트를 정의한 다음 # VM 콘솔에서 $ vagrant provision 명령을 실행한다. - map: example.dev to: /home/vagrant/example/public ``` ## Host 파일 설정 Homestead 설정에서 myproject.dev 란 도메인을 이용했다. 이런 도메인은 존재하지 않는다. 운영체제의 Host 파일을 수정할 것이다. 운영체제에 포함된 'hosts' 파일은 DNS 로 myproject.dev 에 대한 ip 주소 Resolution 요청이 나가기 전에 요청을 낚아 채서, 'hosts' 파일 안에서 찾는다. 사용자가 요청한 도메인에 해당하는 레코드가 있으면 지정된 ip 주소로 이동할 것이다. 텍스트 편집기나 코드 에디터로 `%WINDIR%\System32\drivers\etc\hosts` 파일을 열어 아래 레코드를 추가한다. ```bash # %WINDIR%\System32\drivers\etc\hosts 192.168.10.10 myproject.dev ``` ![](./images/02-install-homestead-windows-img-01.png) ## SSH Key 생성 Homestead 설정에서 `authorize: ~/.ssh/id_rsa.pub` 와 `~/.ssh/id_rsa` 를 설정했는데, 해당 파일은 존재하지 않는다. Git Bash 를 쓸 수 없다면, [PuTTYgen](http://www.chiark.greenend.org.uk/~sgtatham/putty/download.html) 을 설치하고 private, public key pair 를 만들자. ```bash # Git Bash $ mkdir ~/.ssh $ ssh-keygen -t rsa # Enter file in which to save the key (/c/Users/suchc/.ssh/id_rsa): # Enter passphrase (empty for no passphrase): # Enter same passphrase again: ``` ## Homestead 실행 실행해 보자. 처음 실행할 때는 시간이 좀 걸리는데, 이유는 앞에서 Homestead.yaml 에 설정한, ip 주소, public key 복사, 공유 폴더 설정 등을 하기 때문이다. 처음 실행할 때는 방화벽 관련 보안 경고가 뜰 수 있는 데 "허용" 해 주자. ```bash # Git Bash $ cd ~/Homestead && vagrant up # homestead VM 을 중지시킬 때는 $ vagrant halt # 완전히 끌 때는 $ vagrant suspend # Windows Command Prompt \> cd %HOMEPATH%\Homestead \> vagrant up ``` VM 에 로그인하고 Homestead.yaml 설정이 잘 먹었나 확인해 보자. 코맨드 프롬프트에서는 SSH Client 가 없어서 안되는 작업이니, 반드시 Git Bash 를 이용해야 한다. ```bash # Git Bash only $ vagrant ssh # Welcome to Ubuntu 14.04.3 LTS (GNU/Linux 3.19.0-25-generic x86_64) vagrant@homestead:~$ ls ~/myProject # app ... bootstrap ... vagrant@homestead:~$ ifconfig | grep 192.168.10.10 # inet addr:192.168.10.10 Bcast:192.168.10.255 Mask:255.255.255.0 vagrant@homestead:~$ cat ~/.ssh/authorized_keys # ssh-rsa AAAAB3NzaC1...TpJ5HH suchc@homepc ``` **`참고`** ssh 를 이용해 직접 접속하려면 `$ ssh vagrant@myproject.dev -D 2222`. ## 데이터베이스 접속 Host `127.0.0.1`, Port `33060`, Username `homestead`, Password `secret` 로 접속한다. PostgresSQL 의 경우 Port `54320` 으로 접속한다. 필자는 [SQLyog](https://code.google.com/p/sqlyog/wiki/Downloads) 클라이언트를 이용하였다. ![](./images/02-install-homestead-windows-img-02.png) ## 웹 서버 접속 Homestead 에는 Nginx 가 기본으로 탑재되어 있고, Homestead.yaml 의 sites 섹션에서 설정한대로 이미 서비스가 돌고 있는 상태이다. 브라우저에서 'http://myproject.dev' 로 접속해 보자. 테스트용으로 쓸 수 있는 self-signed 인증서가 설치되어 있기 때문에 'https:://myproject.dev' 도 사용할 수 있다. ![](./images/02-install-homestead-windows-img-03.png) --- - [목록으로 돌아가기](../readme.md) ================================================ FILE: lessons/02-install-on-windows.md ================================================ --- extends: _layouts.master section: content current_index: 2 --- # 2강 - 라라벨 5 설치하기 (on Windows) Windows 사용자라면 Mac 으로 전환할 것을 권장한다. 필자는 10년도 훨씬 전에 Windows Server 2000 트랙에서 [MCSE](https://www.microsoft.com/en-us/learning/mcse-certification.aspx) 였었다. 무려 6 주에 걸쳐 매주 한 과목씩 총 6 과목을 시험을 봐야 했었다. 예전엔 Windows 밖엔 쓸 줄 몰랐단 얘기다. 어쨌든 시간이 지나면서 여러 OS 를 경험하고 난 후 필자가 느낀 바는 Windows 는 개인용 PC 운영체제로 나쁘지 않다는 점이다. 서버 운영체제로 쓸 때도 나쁘지 않다. 헌데 **개발자 용으로 쓸 때 Windows 는 Mac 대비 생산성 측면에서 아주아주아주 별로다**. 특히, 콘솔. 아래 그림은 캘리포니아의 어느 개발자 컨퍼런스 모습인데.. 아마 Windows 가 대세인 한국에서 다양한 개발 생산성 도구들을 개발했다면 아래 그림은 완전 역전되었을 지도 모르겠다. ![](http://i2.wp.com/www.dailycal.org/assets/uploads/2013/11/look-at-them-apples.jpg) ## 개발 환경 셋업 시작하기 전에.. 사용자 계정이 한글이거나, 영문이더라도 사용자 계정에 공백이 있다면 반드시 새로운 사용자 계정을 만들고 아래 과정을 수행하시기 바란다. > 홍길동 (x), user name (x), username (o) ### [이 코스에서 사용] 로컬 개발 환경 Windows 사용자에게도 2가지 옵션이 있다. 로컬 PC를 개발 머신으로 쓰거나, 다음 절에 설명하는 Homestead 를 쓰는 방법이다. 이 코스에서는 **로컬 PC를 개발환경으로 쓰는 것을 가정하고 설명**한다. 로컬 PC에 PHP, Mysql 이 설치되어 있지 않다면 [Bitnami Wamp](https://bitnami.com/stack/wamp) 를 이용해 설치하자. Bitnami 를 이용해 설치한 PHP 실행기가 OS 환경변수에 등록되어 있지 않으므로 등록해 주어야 한다. 제어판 -> 시스템 -> 고급 -> 시스템 변수 또는 환경 변수에서 Path 부분을 찾은 다음, PHP 실행기의 경로를 등록한다. 필자의 경우 `C:\Bitnami\wampstack-5.5.30-0\php` 을 등록하였다. 열려 있던 코맨드 프롬프트 창이 있다면, 재 실행 해 주어야 방금 변경한 환경설정의 적용된다는 것을 꼭 기억하자. ![](./images/02-install-on-windows-img-01.png) ![](./images/02-install-on-windows-img-02.png) 그리고, 코맨드 프롬프트 대체 프로그램인 [Git Bash](https://git-for-windows.github.io/) 를 설치하자. ## **[OPTIONAL]** 공짜로 쓰는 개발 서버 "Homestead" 개발팀 구성원들이 동일한 개발 환경을 가지기 위해서, 또는 Production 과 유사한 환경에서 개발하기 위해서 Homestead 사용을 권장한다. Homestead 는 위에서 언급한 필요 확장 모듈이 기본 설치되어 있다. 설정법은 꽤나 까다로우니 [Homestead 설치 (on Windows)](02-install-homestead-windows.md) 를 참고하자. ## 라라벨이 동작하기 위한 PHP 버전 및 필요 모듈 조건 확인 라라벨을 설치하려는 개발환경 또는 서버가 아래 필요사항을 충족하는 지 확인한다. - php 5.5.9 이상 - php Extensions - OpenSSL - PDO - Mbstring - Tokenizer ```bash # Git Bash $ php --version # PHP 5.5.30 $ php -m # Git Bash 에서는 파이프 (`|`) 연산자가 먹지 않아 `grep` 명령을 쓸 수 없다. # Windows Command Prompt \> php --version # PHP 5.5.30 \> php -m | findstr openssl \> php -m | findstr pdo \> php -m | findstr mbstring \> php -m | findstr tokenizer ``` Bitnami Wamp 를 설치했다면 모두 설치되어 있을 것이다. 이미 PHP, Mysql 이 설치되어 있었던 경우에, 필요한 모듈 중 하나라도 빠진게 있다면 구글링해서 설치하자~ ![](./images/02-install-on-windows-img-04.png) ## 이제 라라벨을 설치해 보자. 라라벨 인스톨러를 사용할 것을 권장한다. 왜냐하면 Composer 를 이용해 설치하는 것 보다 훨~씬 빠르니까... 먼저, Composer 가 필요하다. 왜냐하면, 라라벨 인스톨러가 Composer 를 통해 배포되기 때문이다. [윈도우즈용 Composer 인스톨러](https://getcomposer.org/Composer-Setup.exe)를 다운로드 받아 설치하자. 설치 중에 PHP 경로를 물어본다면, 앞 절에서 설정한 경로로 찾아 들어가서 PHP 실행기를 선택해 준다. ```bash \> composer --version # Composer version 1.xx ``` ![](./images/02-install-on-windows-img-05.png) 이제 Composer 를 이용해서 라라벨 인스톨러를 설치한다. ```bash \> composer global require "laravel/installer" ``` `laravel` 코맨드를 어디서든 접근할 수 있게 Path 환경변수를 설정하자. 필자의 경우 `C:\Users\{username}\AppData\{Local|Roaming}\Composer\vendor\bin` 을 등록하였다. 환경 변수가 변경되었으면, 열려 있던 콘솔 창을 재 실행해 주어야 한다. ```bash \> laravel --version # Laravel Installer version 1.2.1 ``` 휴~, 이제 설치를 위한 준비가 완료되었다. 라라벨 인스톨러로 라라벨 5를 설치하자. ```bash \> laravel new myProject \> cd myProject \> php artisan --version # Laravel Framework version 5.x ``` ![](./images/02-install-on-windows-img-06.png) 서버를 부트업하고, 라라벨을 시작해 보자! ```bash # 로컬 서버를 부트한다. \> php artisan serve # 종료하기 ctrl+c # Laravel development server started on http://localhost:8000/ ``` 브라우저에서 `http://localhost:8000` 페이지를 방문해서 'Laravel 5' 란 글씨가 써진 화면이 보인다면, 성공적으로 설치한 것이다. ![](./images/02-install-on-windows-img-07.png) **`참고`** `artisan` 은 라라벨의 코맨드 라인 툴이다. `\> php artisan` 을 실행한 후, 설명을 쭈욱~ 한번 살펴보자. 개발 중에 코드 에디터와 콘솔을 오가면서, `artisan` 코맨드를 많이 사용하게 될 것이다. 프로젝트의 디렉토리 구조와 라라벨의 동작 시퀀스 다이어그램은 Mac 용 설치 문서 "[2강 - 라라벨 5 설치하기](02-hello-laravel.md)"를 참조하자. --- - [목록으로 돌아가기](../readme.md) - [1강 - 처음 만나는 라라벨](01-welcome.md) - [3강 - 글로벌 설정 살펴보기](03-configuration.md) ================================================ FILE: lessons/03-configuration.md ================================================ --- extends: _layouts.master section: content current_index: 3 --- # 3강 - 글로벌 설정 살펴보기 ## Code Editor 와 DB Client - [phpStorm](https://confluence.jetbrains.com/display/PhpStorm/PhpStorm+Early+Access+Program)(1달 Free)을 권장한다. - [Sequel Pro](http://www.sequelpro.com/download)(Free)를 권장한다. 설치하자. ## .env .env에 써진 값들을 config/\*\*.php 에서 `env(string $key)`로 읽을 수 있다. 왜 config/\*\*.php, 가령 database.php 에 직접 하드코드로 쓰지 않을까? 이유는... - local, staging, production 등 어플리케이션 실행 환경에 따라 설정 값이 바뀌어야 할 때 유연하게 대응하기 위해서다. - 패스워드 등 민감한 정보를 버전 컨트롤에서 제외하기 위해서다. (.gitignore 파일을 확인해 보자.) ``` APP_ENV=local # 실행환경 APP_DEBUG=true # 디버그 스위치 APP_KEY= # 32bit Application Key ``` .env 파일이 없다면, 생성하자. ```bash $ cp .env.example .env ``` ## Application Key .env에 설정된 `APP_KEY` 값은 라라벨 프레임웍 전반에 걸쳐 Cipher 알고리즘에서 Seed 값으로 사용된다. 설정되어 있지 않다면 꼭 설정하자. ```bash $ php artisan key:generate ``` ## DB 에 연결하자. 먼저 프로젝트에 사용할 데이터베이스를 설정하자. 라라벨에서 .env 파일 수정만으로 DB 설정이 가능하다. ``` DB_HOST=localhost DB_DATABASE=myProject DB_USERNAME=homestead DB_PASSWORD=secret ``` 실제 DB 접속은 8강 부터 할 것이다. config 디렉토리 아래에 있는 다른 파일들도 살펴 보자. **`참고`** Homestead 에 설치된 mySQL에 접속하려면, port를 33060으로 설정해야 한다. --- - [목록으로 돌아가기](../readme.md) - [2강 - 라라벨 5 설치하기](02-hello-laravel.md) - [4강 - Routing 기본기](04-routing-basics.md) ================================================ FILE: lessons/04-routing-basics.md ================================================ --- extends: _layouts.master section: content current_index: 4 --- # 4강 - Routing 기본기 ## 웰컴 뷰를 가지고 놀자 resources/views/welcome.blade.php 를 연다. 이 파일이 바로 2강에서 'Laravel 5'란 큰 글씨로 우리를 반겨 주었던 바로 그 뷰이다. 모든 내용을 지우고, "Hello World"를 쓴 후, 로컬서버를 부트업하고 브라우저에서 http://localhost:8000으로 접근해 보자. ```html Hello World ``` ```bash $ php artisan serve $ open http://localhost:8000 # 크롬브라우저에 주소를 직접 입력하라는 의미 ``` **`참고`** 앞으로의 실습을 위해 iTerm2의 화면을 그림과 같이 분할하고, #1창에 `$ php artisan serve`, #2창에 `$ php artisan tinker`, #창은 빈 콘솔 로 띄워 놓을 것을 권장한다. 크롬브라우저에는 http://localhost:8000 을 띄워 놓자. 듀얼 모니터라면 한대에 브라우저, 한대에 코드에디터와 콘솔을 띄워 놓으면 좋다. **`중요`** 실습 중에 .env 파일 또는 config/\*\*.php 파일 수정으로 환경 변수가 바뀌면 반드시 로컬 서버를 재실행 해 주어야 한다. ![](./images/04-routing-basic-img-01.png) ## Routing 여기에서 의문점? 분명 홈페이지(/)로 접근했고, welcome.blade.php 와 관련된 어떤 힌트도 제공하지 않았는데 어떻게 이 뷰가 로드되었을까? 요청 Url에 따라 적절한 패스로 연결시켜주는 것이 바로 Routing이 하는 역할이다. app/Http/routes.php 를 열어보자. ```php Route::get('/', function () { return view('welcome'); }); ``` '/' 요청이 오면, function 으로 싸진 Closure가 동작한다는 의미이다. Closure 안을 보면, view()라는 function에 'welcome'이란 인자를 넘겨서 반환된 값을 다시 반환한다. 'welcome'이란 인자는 resources/views/welcome.blade.php 란것을 알 수 있다. 즉, Closure에서 반환된 값이 Http 응답으로 전달된다. `view(string $view)`가 아니라 스트링을 반환하면 어떻게 될까? 브라우저에 스트링이 출력된다. ```php Route::get('/', function () { return 'Hello World'; }); ``` 가령, 라라벨에 기본 내장 되어 있는 resources/views/errors/503.blade.php과 같이 하위 뷰를 응답하려면 어떻게 해야할까? 하위 디렉토리는 '.' 또는 '/'로 구분한다. ```php Route::get('/', function () { return view('errors.503'); }); ``` **`참고`** `view()`는 Helper Function 이다. `return View::make('welcome')`와 같이 라라벨이 제공하는 Facade('파사드' 또는 '빠사드'라 읽는다.)를 이용할 수도 있다. 필자는 `view()->`까지 입력했을 때 코드힌트가 나와서 Helper Function을 더 선호한다. 말 나온 김에, Facade는 Static Access 형태를 빌려 쓰고 있지만, 실제로 백그라운드에서는 Service Container에 의해서 새로운 instance를 생성하여 메소드에 접근하므로, Anti Pattern이 아니다. **`참고`** 방금 살펴본 resources/views/errors/503.blade.php 뷰는 라라벨 어플리케이션이 유지보수 모드에 들어갔을 때 사용자에게 보여주는 뷰이다. `$ php artisan down` 명령으로 유지보수 상태로 전환하고, `$ php artisan up` 으로 서비스 상태로 복귀할 수 있다. 유지보수 모드는 웹 서버를 중지 시킨 것은 아니다. --- - [목록으로 돌아가기](../readme.md) - [3강 - 글로벌 설정 살펴보기](03-configuration.md) - [5강 - 뷰에 데이터 바인딩하기](05-pass-data-to-view.md) ================================================ FILE: lessons/05-pass-data-to-view.md ================================================ --- extends: _layouts.master section: content current_index: 5 --- # 5강 - 뷰에 데이터 바인딩하기 ## 블레이드 템플릿 맛보기 resources/views/index.blade.php 를 만들고 아래와 같이 데이터를 바인딩 시켜 보자. 여기서 `{{ }}`은 라라벨의 템플릿 엔진인 블레이드에서 사용하는 String Interpolation 문법이다. 즉, 뷰 안에서 ``과 같은 역할을 해 주는 것이다. ```html

{{ $greeting }} {{ $name or '' }}. Welcome Back~

``` **`참고`** HTML 스트링등 특수문자가 포함된 데이터를 뷰에 바인딩 시킬 때는 {{ }}대신 {!! !!}을 사용한다. `{{ $name or '' }}`을 php 문법으로 컴파일 하면, 대략 `echo $name ? $name : '';`와 같다. ## with() 메쏘드로 뷰에 데이터 바인딩하는 방법 ```php Route::get('/', function () { $greeting = 'Hello'; return view('index')->with('greeting', $greeting); }); ``` ## with() 메쏘드로 한 개 이상의 데이터를 넘기는 방법 ```php Route::get('/', function () { return view('index')->with([ 'greeting' => 'Good morning ^^/', 'name' => 'Appkr' ]); }); ``` ## view() 의 2번째 인자로 데이터를 넘기는 방법 ```php Route::get('/', function () { return view('index', [ 'greeting' => 'Ola~', 'name' => 'Laravelians' ]); }); ``` **`참고`** 실전에서는 `compact(mixed $varname)` php 내장 함수와 조합하여, `$greeting='World'; return view('index', compact('greeting'));`와 같은 식으로 많이 이용한다. ## view 인스턴스의 Property를 이용하는 방법 ```php Route::get('/', function () { $view = view('index'); $view->greeting = "Hey~ What's up"; $view->name = 'everyone'; return $view; }); ``` --- - [목록으로 돌아가기](../readme.md) - [4강 - Routing 기본기](04-routing-basics.md) - [6강 - 블레이드 101](06-blade-101.md) ================================================ FILE: lessons/06-blade-101.md ================================================ --- extends: _layouts.master section: content current_index: 6 --- # 6강 - 블레이드 101 블레이드는 라라벨의 템플릿 엔진이다. 뷰 안에 포함된 블레이드 문법들은 블레이드 엔진에 의해 php 코드로 컴파일 된다. ## `{{ }}` - String interpolation php echo 코맨드와 같은 역할을 해 준다. ```html {{ $greeting }} ``` ## `{{-- --}}` - Comment HTML 주석으로 컴파일 된다. 그런데 브라우저에서 소스보기로 보면 엄연히 다르다. ```html {{-- count(range(1, 10)) --}} ``` ## `@foreach` resources/views/index.blade.php 에서 `@foreach` 블레이드 문법을 사용해 보자. ```html
    @foreach($items as $item)
  • {{ $item }}
  • @endforeach
``` 당연히 `$items` 변수를 뷰로 넘겨줘야 한다. 어디서? 5강에서 배운 내용이다. app/Http/routes.php 에서... ```php Route::get('/', function () { $items = [ 'Apple', 'Banana' ]; return view('index', compact('items')); }); ``` **`참고`** `@for`도 사용할 수 있다. ## `@if` resources/views/index.blade.php 에서 `@if` 블레이드 문법을 사용해 보자. ```html @if($itemCount = count($items))

There are {{ $itemCount }} items !

@else

There is no item !

@endif ``` **`참고`** `@elseif` 당연히 된다. `@unless (== if(!))`도 사용할 수 있다. ## `@forelse` resources/views/index.blade.php 에서 `@forelse` 블레이드 문법을 사용해 보자. `@forelse`는 `@if`와 `@foreach`의 결합이다. 뷰로 넘어온 변수에 값이 있고 ArrayAccess를 할 수 있으면 , `@forelse`를 타고 그렇지 않으면 `@empty`를 탄다. 자주 이용하게 되니 잘 기억해 두자. ``` @forelse($items as $item)

The item is {{ $item }}

@empty

There is no item !

@endforelse ``` `@forelse` 위에서 ``로 변수를 오버라이드하고 다시 실험해 보자. --- - [목록으로 돌아가기](../readme.md) - [5강 - 뷰에 데이터 바인딩하기](05-pass-data-to-view.md) - [7강 - 블레이드 201](07-blade-201.md) ================================================ FILE: lessons/07-blade-201.md ================================================ --- extends: _layouts.master section: content current_index: 7 --- # 7강 - 블레이드 201 ## @yield, @extends, @section - 마스터 레이아웃 사용하기 페이지마다 반복되는 헤더랑 풋터는 어디에 넣지? 이때 필요한 것이 마스터 레이아웃이다. resources/views/master.blade.php 파일을 만들고, 아래와 같이 HTML 뼈대를 만들어 보자. ```html Laravel 5 Essential @yield('content') ``` **`참고`** phpStorm은 [Emmet](http://docs.emmet.io/)을 지원한다. 위 파일에서 `!` 만 입력한 상태에서 Tab을 누르면 HTML 기본 뼈대가 채워진다. ul>li*3>aTab 을 한번 해 보면 Emmet 문법을 왜 익혀야 하는지 금방 알 것이다. resources/views/index.blade.php는 다음과 같이 수정하고, 브라우저에서 확인해 보자. HTML 소스도 반드시 확인해 보자. ```html @extends('master') @section('content') Your content here !!! @stop ``` 여기서 `@extends`라는 키워드는 index.blade.php가 master.blade.php를 상속한다는 뜻이다. 또 `@yield('content')` 와 `@section('content')`를 주목하자. content라고 이름 지어진 섹션이 마스터 레이아웃에서 yield('양도하다', '넘겨주다' 라는 뜻) 된다는 의미다. 섹션은 원하는 숫자만큼 여러개 만들 수 있다. 뷰에서 만든 섹션의 이름으로 마스터 레이아웃에서 yield해 주기만 하면 된다. 가령, 이런 것이 가능해 진다. ```html @extends('master') @section('style') @stop @section('content') Your content here !!! @stop @section('script') @stop ``` ## @include - 하위 뷰 포함하기 resources/views/footer.blade.php를 만들자. ```html

This is footer

``` resources/views/master.blade.php 또는 resources/views/index.blade.php 어디서든 방금 만든 footer 뷰를 불러와 삽입할 수 있다. ```html @include('footer') ``` --- - [목록으로 돌아가기](../readme.md) - [6강 - 블레이드 101](06-blade-101.md) - [8강 - 날 쿼리 :(](08-raw-queries.md) ================================================ FILE: lessons/08-raw-queries.md ================================================ --- extends: _layouts.master section: content current_index: 8 --- # 8강 - 날 쿼리 :( ## 사용할 테이블을 만들자 [3강 - 글로벌 설정 살펴보기](03-configuration.md)에서 .env 파일에 설정한 내용으로 posts 테이블을 만들어 보자. > **주의** > > 홈스테드 환경 사용자는 데이터베이스 관련 모든 명령을, 호스트 컴퓨터의 쉘이 아니라 홈스테드 쉘에서 수행해야 한다. 뒤에 나올 마이그레이션 등도 마찬가지로 홈스테스 쉘에서 수행해야 한다. ```bash # 아래 명령들은 이런 내용으로 만든다는 내용을 보여주기 위한 것이며 실제로는 GUI 툴로 해도 무방하다. $ mysql -uroot mysql > CREATE DATABASE myProject; mysql > CREATE USER 'homestead' IDENTIFIED BY 'secret'; mysql > GRANT ALTER, CREATE, INSERT, SELECT, DELETE, REFERENCES, UPDATE, DROP, EXECUTE, LOCK TABLES, INDEX ON myProject.* TO 'homestead'; mysql > FLUSH PRIVILEGES; mysql > exit (enter) $ mysql -uhomestead -p mysql > CREATE TABLE posts( -> id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, -> title VARCHAR(255), -> body TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; mysql > INSERT INTO posts(title, body) VALUES('My Title', 'My Body'); mysql > exit (enter) ``` mySql에 root로 로그인하여 myProject DB를 만들고, homestead 사용자에 대해 myProject DB에 대한 접근 권한 부여를 반드시 해야 한다. 아래 그림은 Sequel Pro 에서 권한 부여Cmd + U하는 과정이다. ![](./images/08-raw-queries-img-01.png) ## 라라벨을 이용해서 DB 쿼리를 해 보자. **`참고`** 실제로 이렇게 사용하는 경우는 거의 없으니 참고만 하자. 쿼리를 배우기 위해 라라벨에서 제공하는 REPL 을 이용하자. tinker('어설프게 손보고 고치다' 라는 뜻)라고 불리는 artisan 코맨드인데, 라라벨의 모든 환경이 제공되기 때문에 여러가지 실험적인 시도들을 해보기 편리하다. ```bash $ php artisan tinker Psy Shell v0.5.2 (PHP 5.6.7 — cli) by Justin Hileman >>> (cursor) ``` posts 테이블을 가져와 보자. ```bash >>> DB::select('select * from posts'); => [ {#676 +"id": 1, +"title": "My Title", +"body": "My Body", }, ] ``` 레코드를 더 추가하자. 라라벨은 PDO를 이용하기 때문에 ? 처럼 같이 데이터를 바인딩해 줘야 한다. ```bash >>> DB::insert('insert into posts(title, body) values(?, ?)', ['Second Title', 'Second Body']); => true ``` Collection이 아니라 하나의 Instance만 얻으려면 어떻게 해야 할까? ```bash >>> $post = DB::selectOne('select * from posts where id = ?', [1]); => {#689 +"id": 1, +"title": "My Title", +"body": "My Body", } >>> $post->title; => "My Title" ``` 업데이트도 해 보자. ```bash >>> DB::update('update posts set title="Modified Title" where id = ?', [2]); => 1 ``` --- - [목록으로 돌아가기](../readme.md) - [7강 - 블레이드 201](07-blade-201.md) - [9강 - 쿼리 빌더](09-query-builder.md) ================================================ FILE: lessons/09-query-builder.md ================================================ --- extends: _layouts.master section: content current_index: 9 --- # 9강 - 쿼리 빌더 SQL 문을 php 코드로 쓴 거라고 보면 된다. 지금은 그냥 SQL로 쓰면 될 것, 더 길고 복잡한 코드로 쓰지?라고 반문할 수 있지만.. 차차 그 편리성을 알게 되니 무작정 따라해 보자. ## 쿼리 빌더를 이용해 보자. `DB` Facade와 `table()`, `get()` 메소드를 이용하여 Collection(레코드셋)을 가져 와 보자. ```bash $ php artisan tinker >>> DB::table('posts')->get(); # SELECT * FROM posts => [ {#679 +"id": 1, +"title": "My Title", +"body": "My Body", }, {#680 +"id": 2, +"title": "Modified Title", +"body": "Second Body", }, ] ``` `first()`, `find()` 메소드로 Instance(레코드)를 가져오자. ```bash >>> DB::table('posts')->find(2); => {#688 +"id": 2, +"title": "Modified Title", +"body": "Second Body", } >>> DB::table('posts')->first(); => {#671 +"id": 1, +"title": "My Title", +"body": "My Body", } ``` `where()` 예상대로 where 절을 사용하는 거다. ```bash # 모두 동일한 쿼리이다. where()에서 = 연산자는 생략 가능하다. >>> DB::table('posts')->where('id', '=', 1)->get(); >>> DB::table('posts')->where('id', 1)->get(); >>> DB::table('posts')->whereId(1)->get(); => [ {#692 +"id": 1, +"title": "My Title", +"body": "My Body", }, ] ``` **`참고`** `whereId()`는 Dynamic Method이다. **`참고`** `where()`에 Closure를 쓸 수도 있다 `where(function($query) {$query->where('field', 'operator', 'value);})` `select()`를 이용하여 필요한 필드만 가져오자. ```bash >>> DB::table('posts')->select('title')->get(); => [ {#686 +"title": "My Title", }, {#678 +"title": "Modified Title", }, ] ``` 자주 쓰는 메소드들이다. 공식 문서를 보면 알겠지만, `count()`, `distinct()`, `select(DB::raw('count(*) as cnt'))`, `join()`, `union()`, `whereNull()`, `having()`, `groupBy()`, ... 표현하지 못하는 SQL 문장은 없다고 보면 된다. - `insert(array $value)` - `update(array $values)` - `delete(int $id)` - `lists(string $column)` - `orWhere(string $column, string $operator, mixed $value)` - `limit(int $value)` // == `take(int $value)` - `orderBy(string $column, string $direction)` - `latest()` // == `orderBy('created_at', 'desc')` --- - [목록으로 돌아가기](../readme.md) - [8강 - 날 쿼리 :(](08-raw-queries.md) - [10강 - 엘로퀀트 ORM](10-eloquent.md) ================================================ FILE: lessons/10-eloquent.md ================================================ --- extends: _layouts.master section: content current_index: 10 --- # 10강 - 엘로퀀트 ORM 엘로퀀트는 라라벨의 ORM (Object Relational Mapper, Active Record Pattern 의 구현체)이다. 데이터베이스는 테이블간 관계를 가지고 있다. 데이터베이스를 추상화한 모델 클래스 간에 관계를 맺어 주는 구현체를 일반적으로 ORM이라 칭한다. 뿐만 아니라 라라벨에서는 config/database.php에 의해 설정된 DB Driver와 ORM 사용으로 인해 데이터베이스와 어플리케이션 간에 디커플링 효과도 얻게 된다. 어플리케이션 코드 수정 한 줄 없이 mySQL을 SQLite로 바꿀 수 있다는 의미이다. ## 모델 라라벨이 MVC 프레임웍인데 우리가 이제까지 본 것은 V(뷰) 뿐이었다. 드디어 M에 해당하는 모델을 볼 차례이다. authors 라는 테이블을 생성하자. ```bash $ mysql -uhomestead -p mysql > CREATE TABLE authors( -> id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, -> email VARCHAR(255) NOT NULL, -> password VARCHAR(60) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; mysql > INSERT INTO authors(email, password) VALUES('john@example.com', 'password'); ``` **`참고`** 이번까지는 수작업으로 테이블을 생성했다. 다음 번 부터는 migration을 통해 테이블을 생성하고, seed를 통해 데이터를 생성할 것이다. 모델을 생성하자. artisan CLI에서 제공하는 Generator 를 이용하자. 코맨드를 수행한 후 app/Post.php, app/Author.php 파일이 생성되었는 지 확인하자. ``` $ php artisan make:model Post $ php artisan make:model Author ``` **`참고`** 테이블 이름을 users 와 같이 복수로, 모델 이름은 User 처럼 단수로 하는 것이 규칙이다. DB 테이블은 Collection을 담고 있고, 모델은 DB 테이블에 담긴 하나의 레코드를 클래스로 표현한 것이기 때문이다. 규칙을 지킬 수 없는 경우에는, 가령 모델명을 Author, 테이블을 users 라 했을 경우, 라라벨이 둘 간의 관계를 알 수 있도록 Author 모델에 `protected $table = 'users';` 코드를 추가해 주어야 한다. ## 처음 만나는 엘로퀀트 쿼리 전체 Collection을 가져오자. 이번엔 `DB` Facade를 이용하지 않고, `App\Author` 모델을 이용한 것을 눈여겨 보자. ```bash $ php artisan tinker >>> App\Author::get(); # == DB::table('authors')->get(); => Illuminate\Database\Eloquent\Collection {#677 all: [ App\Author {#678 id: 1, email: "john@example.com", password: "password", }, ], } ``` **`참고`** 쿼리 빌더 에서 사용할 수 있는 대부분의 메소드는 Eloquent Model에서도 사용할 수 있다. 예를 들면, `App\Author::orderBy('id', 'desc')->limit(1)->lists('email');`와 같이. 사실, 이번 강좌에서 보는 것은 엘로퀀트를 상속한 모델을 이용해서 쿼리 빌더를 쓸 수 있다는 정도이다. 엘로퀀트의 전부가 아니란 얘기다. 엘로퀀트 ORM의 꽃은 모델간의 관계 맺기 이며, 18강에서 다루고 있다. 새로운 Instance를 생성하고 데이터베이스에 저장해 보자. ```bash $ php artisan tinker >>> $author = new App\Author; >>> $author->email = 'foo@bar.com'; >>> $author->password = 'password'; >>> $author->save(); # 메모리에만 존재하던 인스턴스를 데이터베이스에 저장한다. # Illuminate\Database\QueryException with message '... updated_at in ...' ``` ## QueryException `save()` 메소드 호출에서 예외가 발생했을 것이다. 엘로퀀트는 모든 모델이 `updated_at`과 `created_at` 필드가 있다고 가정하고, 새로운 Instance가 생성될 때 현재의 timestamp값을 입력하려한다. 그런데, 위 테이블들은 수작업으로 만든 테이블이라 앞서 말한 필드들이 존재하지 않는다. 방법은 필드를 추가하는 방법과, timestamp 입력을 모델에서 끄는 방법이 있는데, 실습을 위해 일단 끄자. ```php // Post 모델도 적용해 주자. class Author extends Model { public $timestamps = false; } ``` **`중요`** **_모델에 변경이 생기면 실행중이던 tinker 를 다시 실행해 주어야 한다._** tinker 가 로드될 시점에 라라벨 구동을 위한 모든 환경이 로드되므로, 이후 변경을 반영하기 위함이다. ctrl + C 또는 `>>> exit` 명령으로 종료할 수 있다. tinker를 재실행 한 후 up 화살표로 이전 코맨드 이력을 탐색할 수 있다. ```bash >>> $author = new App\Author; >>> $author->email = 'foo@bar.com'; >>> $author->password = 'password'; >>> $author->save(); => true ``` ## 다른 메소드를 이용한 모델 생성 이번에는 `save()` 대신 `create()` 메소드를 이용할 것이다. ```bash $ php artisan tinker >>> App\Author::create([ ... 'email' => 'bar@baz.com', ... 'password' => bcrypt('password') ... ]); # Illuminate\Database\Eloquent\MassAssignmentException with message 'email' ``` **`참고`** `bcrypt(string $value)` 은 암호화된 60byte스트링를 만들어 준다. Facade로 쓰면 `Hash::make(string $value)` 와 같다. ## MassAssignmentException timestamps를 무력화 시킨 후에도, `create()` 메소드를 이용할 때는 에러가 발생했다. `create()` 메소드로 모델 인스턴스를 생성할 때는 해당 모델에 `$fillable` 속성을 지정해 주어야 한다. 폼을 통해 사용자가 넘긴 값을 그대로 DB에 넣을 경우를 대비해, 악의적인 필드가 입력되는 것을 방지하기 위한 조치이다. Post와 Author 모델을 열고 `$fillable` 속성을 지정하자. ```php class Author extends Model { protected $fillable = ['email', 'password']; } ``` ```php class Post extends Model { protected $fillable = ['title', 'body']; } ``` tinker 를 재시작하고 up 키를 눌러, `create()` 메소드를 다시 실행해 보자. ```bash $ php artisan tinker >>> App\Author::create([ ... 'email' => 'bar@baz.com', ... 'password' => bcrypt('password') ... ]); => App\Author {#680 # bcrypt() Helper에 의해 암호화된 60 byte 패스워드를 확인하자. email: "bar@baz.com", password: "$2y$10$tL/9voTNRtH7dfE9yULVaOybUWTcNkLRws9gTawcU85L3PEwRotUS", id: 3, } ``` --- - [목록으로 돌아가기](../readme.md) - [9강 - 쿼리 빌더](09-query-builder.md) - [11강 - DB 마이그레이션](11-migration.md) ================================================ FILE: lessons/11-migration.md ================================================ --- extends: _layouts.master section: content current_index: 11 --- # 11강 - DB 마이그레이션 마이그레이션은 데이터베이스를 위한 버전 컨트롤이라 생각하면 된다. 처음 테이블을 생성하고, 가령 이후에 새로운 필드를 추가한다든지, 필드의 이름을 바꾼다든지 등의 이력을 모두 마이그레이션 코드로 남겨 두고, 테이블을 생성했다가 롤백하는 등 자유롭게 이용할 수 있다. 마이그레이션 코드 작성은 정말 지루한 일이다. 토이(toy) 프로젝트를 하는데 마이그레이션을 굳이 작성할 필요는 없다. 하지만, 대형 서비스라면 테이블 스키마를 변경해야 할 수도 있는 새로운 요구사항이 생길 수 있다. 이때 기초공사를 잘못해 두었다면, 개발자에게 엄청난 위기 상황이 닥칠 수도 있는데, 마이그레이션이 위기에서 개발자를 구해주는 데 도움을 줄 것이다. 정말로~ 그리고, 팀으로 여러 명이 테이블 스키마를 변경해 가면서 개발할 때는, `mysqldump` 해서 주고 받는 수고를 피하기 위해 꼭 필요하다. 라라벨에서는 데이터베이스 스키마를 코드로 생성하기 위한 `Blueprint` 클래스를 제공하고 있다. ## Migration 을 만들자. 먼저 기존에 만든 posts, authors 테이블들을 삭제하자. ```bash $ mysql -u homestead -p mysql> SET FOREIGN_KEY_CHECKS = 0; mysql> DROP TABLE posts; mysql> DROP TABLE authors; mysql> SET FOREIGN_KEY_CHECKS = 1; ``` artisan CLI 를 이용해 마이그레이션을 만들자. ```bash $ php artisan make:migration create_posts_table $ php artisan make:migration create_authors_table ``` database/migrations 디렉토리에 timestamp_create_xxx_table 이란 2개의 마이그레이션이 생성된 것을 확인하자. database/migrations/timestamp_create_posts_table 을 열어보면 `up()`, `down()` 2개의 메소드가 생성된 것을 확인할 수 있다. `up()` 은 마이그레이션을 실행할 때 동작하는 메소드이고 (`$php artisan migrate`), `down()`은 직전 마이그레이션을 롤백 하기 위한 메소드이다 (`$ php artisan migrate:rollback`). ```php // CreateAuthorsTable 도 작성하자. class CreatePostsTable extends Migration { public function up() { Schema::create('posts', function($table) { $table->increments('id'); // id INT AUTO_INCREMENT PRIMARY KEY $table->string('title', 100); // title VARCHAR(100) $table->text('body'); // body TEXT $table->timestamps(); // created_at TIMESTAMP, updated_at TIMESTAMP }); } public function down() { Schema::dropIfExists('posts'); // DROP TABLE posts } } ``` `up()` 에서는 `Schema` Facade 의 `create()` 메소드를 이용하는데, 첫번째 인자는 테이블 이름, 두번째 인자는 콜백이다. 콜백은 `$table`이란 Blueprint 인스턴스를 주입하며, `string()`, `text()`, `integer()`, `timestamp()` 등 데이터베이스의 데이터 타입에 해당하는 다양한 메소드를 제공한다. 마이그레이션을 실행해 보자. ```bash $ php artisan migrate # Migration table created successfully. # Migrated: 2014_10_12_000000_create_users_table # Migrated: 2014_10_12_100000_create_password_resets_table # Migrated: 2015_11_10_080603_create_posts_table # Migrated: 2015_11_10_080609_create_authors_table ``` 테이블이 정상적으로 생성되었는 지 확인하자. ```bash $ mysql -u homestead -p mysql> use myProject; mysql> describe posts; mysql> describe authors; ``` **`참고`** 마이그레이션을 처음 실행하면, migrations, users, password_resets 등과 같이 라라벨이 기본 내장된 마이그레이션도 같이 실행되어 해당 테이블들이 같이 생성된다. ## 롤백해 보자. ```bash $ php artisan migrate:rollback # 롤백되는 것이 잘 확인되었으면 다음 실습을 위해 다시 마이그레이션하자. $ php artisan migrate ``` ## 필드를 추가 authors 테이블에 name 필드를 추가하는 것을 깜빡했다고 가정하자. 물론, 전체 롤백을 하고, 최초 테이블 생성 마이그레이션에 name 필드를 추가한 뒤 마이그레이션을 다시 실행할 수 도 있다. 그런데, 만약 테이블에 데이터가 있다면... 난감해 진다. 필드를 추가하는 마이그레이션을 작성해 보자. ```bash $ php artisan make:migration add_name_to_authors_table ``` ```php use Illuminate\Database\Schema\Blueprint; class AddNameToAuthorsTable extends Migration { public function up() { Schema::table('authors', function(Blueprint $table) { $table->string('name')->after('email')->nullable(); // nullable()은 NULL 을 허용한다는 얘기 }); } public function down() { Schema::table('authors', function(Blueprint $table) { $table->dropColumn('name'); }); } } ``` 테이블을 새로 생성할 때 쓰던 `create()`가 아니라, 이미 만들어진 테이블에 스키마를 변경하는 것이라 `table()` 메소드를 쓴 것에 주목하자. `after()`는 mySql에서만 쓸 수 있는 메소드로 인자로 넘겨 받은 필드 다음에 새로운 필드를 추가해 준다. 마이그레이션을 실행하고, 필드가 추가되었는 지 확인해 보자. ```bash $ php artisan migrate ``` **`팁`** 이번 마이그레이션에서는 Closure Function 인자에 Blueprint 라고 TypeHint 를 썼다. TypeHint 를 쓰면 phpStorm 에서 `->` 로 코드 힌트를 볼 수 있어서 편리하다. **`참고`** `Blueprint` 의 풀 네임스페이스는 `\Illuminate\Database\Schema\Blueprint` 이다. 코드 내에 두 군데 이상 같은 클래스가 쓰인다면 `use` 키워드로 import 해 주어야 한다. phpStorm 은 Preference 셋팅에 따라 Blueprint 타이핑이 끝나고 나면 자동으로 import 를 해 주어 편리한데, 혹 자동 import 가 안되었다면 `Blueprint` 위에 커서를 놓고 option + Enter 을 눌러 컨텍스트 메뉴를 띄운 후 import 할 수도 있다. ## Reset & Refresh `migrate:rollback` 이 직전 마이그레이션만 롤백하는 반면 `migrate:reset` 는 모든 마이그레이션을 롤백하고 데이터베이스를 초기화 시킨다. `migrate:refresh` 는 리셋을 실행해서 데이터베이스를 청소한 후, 마이그레이션을 처음부터 다시 실행하는 코맨드이다. --- - [목록으로 돌아가기](../readme.md) - [10강 - 엘로퀀트 ORM](10-eloquent.md) - [12강 - 컨트롤러](12-controller.md) ================================================ FILE: lessons/12-controller.md ================================================ --- extends: _layouts.master section: content current_index: 12 --- # 12강 - 컨트롤러 MVC에서 V(뷰)와 M(모델)을 살펴보았다. 이제 마지막 콤포넌트인 C(컨트롤러)를 살펴볼 차례이다. ## Revisit Route 이제까지 모든 HTTP 요청에 대한 처리를 app/Http/routes.php의 Route Closure에서 처리 했다. Router의 역할은 HTTP 요청을 적절한 처리 로직으로 연결시켜주는 것, 즉 컨트롤러에 연결시켜주는 것이다. app/Http/routes.php에 컨트롤러를 이용한 Route를 만들어 보자. ```php Route::get('/', 'IndexController@index'); ``` HTTP GET / 요청이 들어오면, `IndexController`의 `index()` 메소드로 연결시키라는 뜻이다. ## Controller를 만들자. 서버를 부트업하고, '/' 경로로 접근해 보자. `ReflectionException - Class App\Http\Controllers\IndexController does not exist` 란 메시지가 출력되었을 것이다. IndexController가 없기 때문이다. artisan CLI를 이용해서 만들자. ```bash $ php artisan make:controller IndexController ``` app/Http/Controllers/IndexController 가 생성된 것을 확인하자. `index()` 메소드를 만들자. ```php class IndexController extends Controller { public function index() { return view('index'); } } ``` 서버를 부트업하고, / 경로로 접근하여, 정상적으로 뷰가 표시된 것을 확인하자. 4강에서 Route에 Closure에 넣은 내용과 `index()` 메소드의 내용이 동일하다는 것을 인지했을 것이다. Route에 모든 비즈니스 로직을 넣을 수 없을 뿐더러, 코드를 효율적으로 구조화시키기 위해서 컨트롤러를 사용하여 구조화한 것이라 생각하면 된다. --- - [목록으로 돌아가기](../readme.md) - [11강 - DB 마이그레이션](11-migration.md) - [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) ================================================ FILE: lessons/13-restful-resource-controller.md ================================================ --- extends: _layouts.master section: content current_index: 13 --- # 13강 - RESTful 리소스 컨트롤러 REST는 이 코스 범위를 넘어가는 내용이니, 시간 날 때 구글링을 통해서 공부하자. 모든 HTTP 요청 Url 엔드포인트에 대해서 `IndexController@method` 와 같이 연결하면 Route 정의만으로도 수백, 수천 줄이 될 수 있다. REST 원칙에 따라 리소스 이름으로된 Url 엔드포인트를 정의하고, `@method` 없이 컨트롤러에 연결시키는 것이 리소스 컨트롤러라고 이해하고 넘어가자. ## RESTful Resource Route 아래는 Post 모델에 대한 Url 엔드포인트를 'posts'라 했을 때, REST 원칙에 따라 라라벨이 자동으로 생성해 주는 Url 엔드포인트와 PostsController의 메소드간의 연결을 표로 표현한 것이다. Verb|Endpoint|Method Override|Controller Method|Description ---|---|---|---|--- GET|/posts/| |`index()`|Post 모델 Collection 보기 GET|/posts/{id}| |`show()`|id를 가지는 Post Instance 보기 GET|/posts/create| |`create()`|새로운 Post Instance 생성을 위한 폼 POST|/posts| |`store()`|새로운 Post Instance 생성 GET|/posts/{id}/edit| |`edit()`|id를 가진 Post Instance 업데이트 폼 POST|/posts/{id}|`_method=PUT` `(x-http-method-override: PUT)`|`update()`|id를 가진 Post Instance 업데이트 POST|/posts/{id}|`_method=DELETE` `(x-http-method-override: DELETE)`|`delete()`|id를 가진 Post Instance 삭제 app/Http/routes.php에 posts 경로에 대한 resource 라우트를 정의하자. 그간 배웠던 `get()` 메소드가 아닌, `resource()`란 메소드를 쓰는 것에 유의하자. ```php Route::resource('posts', 'PostsController'); ``` artisan CLI 로 Route 목록을 확인해 보자. ```bash $ php artisan route:list # ReflectionException - Class App\Http\Controllers\PostsController does not exist ``` ## RESTful Resource Controller 만들기 artisan CLI 로 PostsController를 만들자. 이번엔 --plain 옵션이 빠진다. ```bash $ php artisan make:controller PostsController --resource # Route 목록을 다시 확인해 보자. $ php artisan route:list ``` ![](./images/13-restful-resource-controller-img-02.png) ## 테스트 app/Http/Controller/PostsController.php 가 만들어졌는지 확인하자. PostsController의 각 메소드에 Dummy 반환값을 넣고 RESTful 라우트와 컨트롤러가 잘 동작하는지 확인해 보자. ```php class PostsController extends Controller { public function index() { return '[' . __METHOD__ . '] ' . 'respond the index page'; } public function create() { return '[' . __METHOD__ . '] ' . 'respond a create form'; } public function store(Request $request) { return '[' . __METHOD__ . '] ' . 'validate the form data from the create form and create a new instance'; } public function show($id) { return '[' . __METHOD__ . '] ' . 'respond an instance having id of ' . $id; } public function edit($id) { return '[' . __METHOD__ . '] ' . 'respond an edit form for id of ' . $id; } public function update(Request $request, $id) { return '[' . __METHOD__ . '] ' . 'validate the form data from the edit form and update the resource having id of ' . $id; } public function destroy($id) { return '[' . __METHOD__ . '] ' . 'delete resource ' . $id; } } ``` 테스트를 위해 [PostMan 크롬 확장 프로그램](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop)을 사용할 것을 권장한다. 이 문서의 표 대로 하나씩 대입해 보자. PostMan에서 GET을 선택하고 http://localhost:8000/posts, http://localhost:8000/posts/1, http://localhost:8000/posts/1/edit. ![](./images/13-restful-resource-controller-img-01.png) 그럼, HTTP 요청 메소드를 POST로 바꾸고, http://localhost:8000/posts를 해보자. ## TokenMismatchException 라라벨은 CSRF(Cross Site Request Forgery) 공격을 방지하기 위해 기존 데이터를 변경하는 행위, 즉, 신규 생성, 업데이트, 삭제 등의 행위에 대해서는 CSRF 토큰을 폼요청에서 제공해야 한다. 가령 `PostsController@create` 메소드에서 응답한 모델 생성 폼에서 숨은 필드로 `_token` 값을 제공해야 한다. 폼 요청을 받은 `PostsController@store` 메소드는 토큰의 유효성을 확인하고, 같은 세션일 경우, 즉, `create()`를 요청한 클라이언트와 `store()`를 요청한 클라이언트가 동일할 경우에만 `store()` 액션을 수행한다. 지금 우리가 PostMan을 통해서 테스트하는 행위 자체가 CSRF 공격이라 볼 수 있다. 우선 이번 테스트를 위해 CSRF 보호기능을 잠시 끄도록 하자. app/Http/Middleware/VerifyCsrfToken.php를 아래 처럼 수정한다. ```php class VerifyCsrfToken extends BaseVerifier { protected $except = [ 'posts', 'posts/*' ]; } ``` POST http://localhost:8000/posts가 정상 동작하는 것을 확인한 후, 이번에는 POST http://localhost:8000/posts/1 으로 요청해 보자. 또 에러가 날 것이다. ## MethodNotAllowedHttpException 브라우저들은 PUT, DELETE 등의 HTTP 동사(==메소드)를 지원하지 않는다. 즉, 브라우저에서는 PUT, DELETE등의 요청을 할 수 없다는 얘기다. 그럼에도 불구하고, REST 원칙을 지키기 위해서 라라벨 뿐 아니라 대부분의 웹 프레임웍들이 메소드 오버라이딩을 사용한다. POST로 폼 전송을 하되 숨은 필드로 `_method=PUT` 등과 같이 "내 비록 POST로 요청하지만 이건 PUT 요청이오~" 라고 서버 프레임웍에게 힌트를 주는 방법이다. http://localhost:8000/posts/1 로 PUT, DELETE 요청을 하기 위해서는 폼데이터로 `_method=PUT`, `_method=DELETE` 를 추가해 주어야 한다. PostMan에 Body라 써진 탭을 열고, form-data 에 Key:"_method", Value:"PUT"을 각각 입력한 후 다시 테스트해 보자. **`참고`** 숨은 필드를 이용하지 않고 `x-http-method-override: DELETE` 와 같이 HTTP Header를 이용해서 메소드 오버라이딩을 할 수도 있다. 모든 테스트가 끝났으면, `VerifyCsrfToken` 클래스에서 예외 처리 했던 것을 원복 시키자. ```php class VerifyCsrfToken extends BaseVerifier { protected $except = []; } ``` --- - [목록으로 돌아가기](../readme.md) - [12강 - 컨트롤러](12-controller.md) - [14강 - 이름 있는 Route](14-named-routes.md) ================================================ FILE: lessons/14-named-routes.md ================================================ --- extends: _layouts.master section: content current_index: 14 --- # 14강 - 이름 있는 Route Named Routes는 여러모로 유용하다. 컨트롤러에서 `redirect(string $to)` Helper Function 으로 이동할 Url을 만들거나, 뷰 안에서 다른 Url로 이동하는 링크를 만들 때, 하드코드로 Url을 써 놓는 것 보다 여러 모로 관리상 편리하다. 가령, posts 라는 Url 엔트포인트를 어느날 갑자기 articles 로 모두 바꾸어야 한다고 생각해 보라. 모든 컨트롤러와 뷰를 찾아 다니면서 Url을 변경하는 것이 얼마나 귀찮을까? ## Route 에 이름을 주자 app/Http/routes.php 에서 공부해 보자. Route 메소드의 두번째 인자로 배열을 사용하고, 배열의 키로 'as'를 사용하여 라우트의 이름을 지정한다. 컨트롤러로의 연결은 'uses' 키를 사용한다. ```php Route::get('posts', [ 'as' => 'posts.index', 'uses' => 'PostsController@index' ]); ``` `$ php artisan route:list`로 Name 컬럼을 확인해 보자. 이제 컨트롤러나 뷰에서 'posts.index'란 이름을 사용할 수 있다. 가령 `return redirect(route('posts.index'))` 또는 `목록으로 돌아가기`와 같은 식으로 말이다. ```bash $ php artisan tinker >>> route('posts.index'); => "http://localhost/posts" ``` 어 틀린데... 포트번호 8000이 빠졌잖아. 클라이언트가 브라우저일 경우 host와 port를 자동으로 인지하지만, 코맨드 라인에서는 HTTP 환경 변수가 없기 때문이다. `config/app.php`을 수정하자. ```php 'url' => 'http://localhost:8000', ``` Closure로 쓸 때도 이름을 부여할 수 있다. ```php Route::get('posts', [ 'as' => 'posts.index', function() { return view('posts.index'); } ]); ``` ## RESTful Resource Route의 이름 ```php Route::resource('posts', 'PostsController'); ``` `$ php artisan route:list` 로 확인해 보자. Route 이름이 자동으로 부여 된다. 이제 팅커링 해 보자. ```bash $ php artisan tinker >>> route('posts.index'); => "http://localhost:8000/posts" >>> route('posts.store'); => "http://localhost:8000/posts" >>> route('posts.create'); => "http://localhost:8000/posts/create" >>> route('posts.destroy', 1); => "http://localhost:8000/posts/1" >>> route('posts.show', 1); => "http://localhost:8000/posts/1" >>> route('posts.update', 1); => "http://localhost:8000/posts/1" >>> route('posts.edit', 1); => "http://localhost:8000/posts/1/edit" ``` **`참고`** 중복된 Route의 경우, 항상 위에 정의된 것이 아래에 정의된 것을 오버라이드 한다. 가령 posts/count 라는 Route가 있다면 RESTful Resource 정의보다 먼저(== 위에) 정의하는게 안전하다. --- - [목록으로 돌아가기](../readme.md) - [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) - [15강 - 중첩된 리소스](15-nested-resources.md) ================================================ FILE: lessons/15-nested-resources.md ================================================ --- extends: _layouts.master section: content current_index: 15 --- # 15강 - 중첩된 리소스 특정 리소스에 딸린 하위 리소스를 보여줘야 하는 경우가 있다. 가령, Post id 1번에 딸린 Comment 목록을 보여주거나, Comment를 생성/수정/삭제하는 경우 등이다. ## 중첩 리소스 Route를 만들자. app/Http/routes.php에서 아래와 같이 작성해 보자. ```php Route::resource('posts.comments', 'PostCommentController'); ``` artisan CLI 로 PostCommentController 를 만들자. ```bash $ php artisan make:controller PostCommentController --resource ``` `$ php artisan route:list`로 확인해 보자. `posts/{posts}/comments/{comments}` 형태의 라우트를 얻을 수 있다. ![](./images/15-nested-resources-img-01.png) ## Route Parameter 접근 Controller에서 `$postId`와 `$commentId`에 접근할 수 있다. ```php class PostCommentController extends Controller { public function index($postId) { // GET http://localhost:8000/posts/1/comments return '[' . __METHOD__ . "] \$postId = {$postId}"; // [App\Http\Controllers\PostCommentController::index] $postId = 1 } ... public function show($postId, $commentId) { // GET http://localhost:8000/posts/1/comments/20 return $postId . '-' . $commentId; // [App\Http\Controllers\PostCommentController::show] $postId = 1, $commentId = 20 } } ``` --- - [목록으로 돌아가기](../readme.md) - [14강 - 이름 있는 Route](14-named-routes.md) - [16강 - 사용자 인증 기본기](16-authentication.md) ================================================ FILE: lessons/16-authentication.md ================================================ --- extends: _layouts.master section: content current_index: 16 --- # 16강 - 사용자 인증 기본기 좀 과장해서, 어떤 프로젝트든 로그인이 거의 업무량의 절반이라고들 한다. 그만큼 User 모델과 연결된 기능들이 많다는 의미로 이해하면 되겠다. 서비스에 들어온 사용자를 식별하는 방법을 인증(Authentication)이라 한다. 바꾸어 말하면, 사용자가 제시한 신분증이 DB에 저장된 User 정보와 동일한지 확인하고, 맞으면 통과시켜 주면서, 세션이라는 명찰을 하나 나눠 주는 행위라 이해할 수 있다. 이번 강좌에서는 라라벨이 제공하는 메소드들을 이용해서 사용자 인증을 직접 구현해 보고, 다음 강좌에서 라라벨에 포함되어 배포(Shipping)되는 네이티브 인증 구현을 살펴보자. # User 모델 라라벨에는 User 모델과 마이그레이션이 이미 포함되어 있다. app/User.php를 살펴보자. ```php class User extends Model implements ... { protected $fillable = ['name', 'email', 'password']; protected $hidden = ['password', 'remember_token']; } ``` `$fillable` 속성을 통해 name, email, password 필드는 MassAssign 이 가능하다는 것을 알 수 있다. `$hidden` 속성은 외부에 노출되지 않는 필드들을 정의한 것이다. 그럼, 사용자를 하나 만들어 보자. ```bash $ php artisan tinker >>> $user = new App\User; >>> $user->name = 'John Doe'; >>> $user->email = 'john@example.com'; >>> $user->password = bcrypt('password'); >>> $user->save(); ``` **`참고`** 마이그레이션을 실행한 적이 없다면, `$ php artisan migrate` 을 먼저 하자. 잘 생성되었나 확인해 보자. ```bash # $hidden 속성에 의해 password와 remember_token은 노출되지 않는다. $ php artisan tinker >>> App\User::first(); => App\User {#684 id: 1, name: "John Doe", email: "john@example.com", created_at: "2015-11-10 09:20:09", updated_at: "2015-11-10 09:20:09", } ``` ## 사용자를 인증해 보자. 기본을 이해하기 위해 이번에도 app/Http/routes.php를 이용하자. ```php Route::get('auth', function () { $credentials = [ 'email' => 'john@example.com', 'password' => 'password' ]; if (! Auth::attempt($credentials)) { return 'Incorrect username and password combination'; } return redirect('protected'); }); Route::get('auth/logout', function () { Auth::logout(); return 'See you again~'; }); Route::get('protected', function () { if (! Auth::check()) { return 'Illegal access !!! Get out of here~'; } return 'Welcome back, ' . Auth::user()->name; }); ``` 서버를 부트업하고 브라우저에서 'auth' Route로 접근해 보자. 인증이 성공되고 바로 'protected' Route로 넘어가는 것을 확인할 수 있다. 이해를 돕기 위해, 'auth' Route의 Closure에다 인증에 필요한 정보를 하드코드를(`$credentials`) 박았다. 실전에서는 `Request::input('email')`과 같은 식으로 받아야 한다. **`Auth::attempt()` 메소드에 `$credentials`를 넘기면, 단순히 true/false만 리턴하는 것이 아니라, 백그라운드에서는 서버에 로그인한 사용자의 세션도 생성한다**는 것을 기억하자. 정상적으로 로그인되고 Redirect되어 'protected' Route로 들어 왔다면 사용자의 세션 정보가 서버에 생성되었을 것이다. 사용자가 로그인되어 있는 지 확인하는 메소드가 `Auth::check()` 이다. 실험을 위해 브라우저에서 'auth/logout' Route로 접근하여 (`Auth::logout()`) 사용자를 로그아웃 시킨 후, 'protected' Route로 다시 접근해 보자. ## 'auth' 미들웨어 여기서 잠깐! 만약 로그아웃한 후 'protected' Route를 방문했는데 if 블럭이 없었다면 어떻게 될까? `ErrorException-Trying to get property of non-object` 가 발생했을 것이다. 로그인 되지 않았기 때문에 `Auth::user()` 는 null 이고, `null->name`은 성립되지 않기 때문에 예외가 발생한 것이다. if 블럭을 사용하지 않고 Null Pointer를 예방하는 방법이 바로 'auth' 미들웨어 이다. 'auth' 미들웨어를 사용하도록 app/Http/routes.php 를 수정해 보자. ```php Route::get('protected', [ 'middleware' => 'auth', function () { return 'Welcome back, ' . Auth::user()->name; } ]); ``` 로그아웃한 후 'protected' Route로 다시 접근해 보자. ## NotFoundHttpException 'auth' 미들웨어는 app/Http/Middleware/Authenticate.php 에 위치하고 있다. 코드를 들여다 보면, 로그인이 안되어 있으면 'auth/login' 으로 Redirect 하도록 되어 있다. 해당 Route가 없어서 그런 것이니, 만들자. app/Http/routes.php 를 다시 수정하고, 'protected' Route를 방문해 보자. ```php Route::get('auth/login', function() { return "You've reached to the login page~"; }); ``` **`참고`** 방금 본 'auth' 미들웨어는 Route by Route로 사용자가 선택해서 적용할 수 있는 'Route 미들웨어'라 한다. 반면, 앞 강좌에서 보았던 'csrf' 미들웨어는 'HTTP 미들웨어'라 칭한다. HTTP 미들웨어는 모든 HTTP 요청이 무조건 거쳐야 하는 글로벌 미들웨어라고 생각하면 된다. --- - [목록으로 돌아가기](../readme.md) - [15강 - 중첩된 리소스](15-nested-resources.md) - [17강 - 라라벨에 내장된 사용자 인증](17-authentication-201.md) ================================================ FILE: lessons/17-authentication-201.md ================================================ --- extends: _layouts.master section: content current_index: 17 --- # 17강 - 라라벨에 내장된 사용자 인증 16강에 이어 이번 강좌에서는 라라벨에 딸려서 배포(Shipping)되는 인증 기능을 살펴 보자. 내장된 사용자 인증 기능은 특정 시간내에 로그인 실패가 많으면 로그인을 제한하는 쓰로틀링 기능이 포함되어 있고, 좀 더 나이스하게 코드를 구조화해 놓았다. 핵심은 16강과 동일하다. ## Controllers app/Http/Controllers/Auth 디렉토리를 보면 2개의 파일이 보인다. - AuthController.php - 새 사용자 등록과 로그인/로그아웃 로직을 포함하고 있다. - PasswordController.php - 패스워드 리셋 로직을 포함하고 있다. (이 강좌에서는 다루지 않는다.) ## Routes app/Http/routes.php 를 열어 아래 내용을 추가 하자. ```php Route::get('/', function() { return 'See you soon~'; }); Route::get('home', [ 'middleware' => 'auth', function() { return 'Welcome back, ' . Auth::user()->name; } ]); // Authentication routes... Route::get('auth/login', 'Auth\AuthController@getLogin'); Route::post('auth/login', 'Auth\AuthController@postLogin'); Route::get('auth/logout', 'Auth\AuthController@getLogout'); // Registration routes... Route::get('auth/register', 'Auth\AuthController@getRegister'); Route::post('auth/register', 'Auth\AuthController@postRegister'); ``` **`참고`** `AuthController`를 눈 씻고 찾아 봐도, `getLogin()` 메소드를 찾을 수 없다. 이들 메소드는 `Illuminate\Foundation\Auth\AuthenticateUsers` 트레이트 에서 찾을 수 있다. ## Views 뷰는 라라벨에 기본 포함되어 있지 않다. [공식 문서](http://laravel.com/docs/authentication#included-views)를 참조해서 뷰를 만들자. ### 로그인 폼 - resources/views/auth/login.blade.php ```html @extends('master') @section('content')
{!! csrf_field() !!}
Email
Password
Remember Me
@stop ``` ### 사용자 등록 폼 - resources/views/auth/register.blade.php ```html @extends('master') @section('content')
{!! csrf_field() !!}
Name
Email
Password
Confirm Password
@stop ``` ## 실험해 보자. 서버를 부트업하고, 'auth/register' 와 'auth/login' Route를 방문해 보자. 사용자를 등록하고, 로그인/로그아웃('auth/logout') 해보자. **`참고`** 라라벨에서 기본 제공하는 인증에서 패스워드는 최소 6자리 이상이어야 한다. `App\Http\Controllers\Auth\AuthController@validator` 메소드를 보면 `'password' => 'required|confirmed|min:6'` 라고 유효성 검사 규칙이 지정되어 있는 것을 확인할 수 있다. 유효성 검사, 뷰에 쓰인 `old()` 함수에 대해서는 뒤에서 다시 살펴보도록 하자. **`참고`** 다음으로 넘어가기 전에 로그인 뷰에서 소스보기를 해 보자. `` 란 라인을 볼 수 있다. 바로 `csrf_field()` Helper Function이 CSRF 공격을 막기 위해 만든 토큰을 담은 숨은 입력 폼이다. 13강에서 공부한 내용이다. `{!! !!}`은 '<', '>' 같은 특수문자의 '<', '>' Escaping 되는 것을 막기 위한 블레이드의 Interpolation 문법이다. --- - [목록으로 돌아가기](../readme.md) - [16강 - 사용자 인증 기본기](16-authentication.md) - [18강 - 모델간 관계 맺기](18-eloquent-relationships.md) ================================================ FILE: lessons/18-eloquent-relationships.md ================================================ --- extends: _layouts.master section: content current_index: 18 --- # 18강 - 모델간 관계 맺기 쿼리빌더 없이 여러 개의 테이블에서 Join Query하는 것은 정말 번거로운 일이다. 엘로퀀트 ORM을 이용해서 모델간에 관계를 연결하고, 손쉽게 관계된 모델의 속성값들에 접근해 보자. ## 테이블 연결 이전 강좌를 통해 만든 users와 posts 테이블 간의 관계를 생각해 보자. User는 여러 개의 Post를 만들 수 있다. 하나의 Post는 한명의 User에 속한다. 즉, one to many 관계가 형성된다. 테이블을 수정하기 위해 새로운 마이그레이션을 작성하자. ```bash $ php artisan make:migration add_user_id_to_posts_table ``` ```php class AddUserIdToPostsTable extends Migration { public function up() { Schema::table('posts', function(Blueprint $table) { $table->integer('user_id')->unsigned()->after('id'); $table->foreign('user_id')->references('id')->on('users') ->onUpdate('cascade')->onDelete('cascade'); }); } public function down() { Schema::table('posts', function(Blueprint $table) { $table->dropForeign('posts_user_id_foreign'); $table->dropColumn('user_id'); }); } } ``` `up()` 메소드를 살펴보자. posts 테이블에 user_id 필드를 생성한다. user_id가 음수가 될 수 없으므로 `unsigned()`를 체인했다. 테이블을 생성할 때 외래 키 연결을 하지 않았으므로, 마이그레이션에서 `foreign()` 메소드를 이용하여 `posts_user_id_foreign` 란 이름을 가진 관계를 생성하였다. `references()`, `on()`, `onUpdate()`, `onDelete()` 메소드 들을 사용한 것을 주의 깊게 살펴 보자. `down()` 메소드는 `dropForeign()`을 `dropColumn()` 보다 먼저 호출했다. 이유는 외래키 연결이 있는 관계에서 컬럼을 삭제할 수 없기 때문이다. 마이그레이션하자. ```bash $ php artisan migrate ``` ## 모델간 관계를 연결하자. User has many Post. Post belongs to a User. 이 관계를 기억하고, User 모델을 열어 보자. ```php class User extends Model implements ... { public function posts() { return $this->hasMany('App\Post'); } } ``` `$this (== App\User)`는 여러 개의 `App\Post`를 가질 수 있다라는 식으로 읽으면 되겠다. `posts()`라는 복수 형태로 쓴 것을 유의하자. 실제로 `posts()` 메소드의 최종 결과값이 Collection이기 때문이다. 가령, 메소드 이름을 전혀 생뚱한 `abc()`로 해도 되지만, 사람이 읽어 이해할 수 있는 이름으로 짓는게 좋은 습관이다. 팅커링해 보자. ```bash $ php artisan tinker >>> App\User::find(1)->posts()->get(); => Illuminate\Database\Eloquent\Collection {#686 all: [], } ``` [] 이 반환되었다. id 1번을 갖는 User 가 생성한 Post가 없기 때문이다. 만들어 보자. **`팁`** 앞 강의에서 Post 모델에 수정했던 `public $timestamps = false;` 코드는 삭제하자. ```bash $ php artisan tinker >>> App\User::find(1)->posts()->create([ ... 'title' => 'My First Article', ... 'body' => 'My post body...' ... ]); => App\Post {#697 title: "My First Article", body: "My post body...", user_id: 1, updated_at: "2015-11-10 12:33:35", created_at: "2015-11-10 12:33:35", id: 1, } ``` `find()`로 가져온 User 인스턴스에 `posts()->create()` 메소드를 체인한 것을 주의 깊게 보자. 생성된 Post 모델에 user_id가 자동으로 1이 입력된 것을 확인하자. 좀 더 팅커링해 보자. ```bash $ php artisan tinker >>> $posts = App\User::find(1)->posts()->get(); # == App\User::find(1)->posts => Illuminate\Database\Eloquent\Collection {#687 all: [ App\Post {#674 id: 1, user_id: 1, title: "My First Article", body: "My post body...", created_at: "2015-11-10 12:33:35", updated_at: "2015-11-10 12:33:35", }, ], } >>> $posts[0]->title; => "My First Article" >>> $post = App\Post::find(1); => App\Post {#689 id: 1, user_id: 1, title: "My First Article", body: "My post body...", created_at: "2015-11-10 12:33:35", updated_at: "2015-11-10 12:33:35", } >>> $post->user()->get(); # BadMethodCallException with message 'Call to undefined method Illuminate\Database\Query\Builder::user()' ``` `App\User::find(1)->posts` 형태로도 1번 id User의 Post Collection을 가져올 수 있다는 것을 기억하자. 실전에서는 `Auth::user()->posts` 와 같이 현재 로그인한 사용자의 모든 Post 를 가져오는 식으로 많이 사용하게 될 것이다. ## 반대 방향 관계 (Reverse Relationship) $user->posts 가 가능하다면, $post->user 도 가능해야 한다. 그런데 위 팅커링에서는 `BadMethodCallException`이 발생했다. 이유는 User 모델에 Post와 관계를 설정해 주었지만, Post 모델에는 User와의 관계가 설정되어 있지 않기 때문이다. Post 모델을 열자. ```php class Post extends Model { public function user() { return $this->belongsTo('App\User'); } } ``` `$this (== App\Post)`는 `App\User`에 속한다 라고 읽어 보자. 이제 역방향 관계 설정이 잘 되었는 지 확인해 보자. ```bash $ php artisan tinker >>> App\Post::find(1)->user()->first(); => App\User {#691 id: 1, name: "John Doe", email: "john@example.com", created_at: "2015-11-10 09:20:09", updated_at: "2015-11-10 09:36:52", } ``` **`참고`** 위 예에서 posts 테이블에 컬럼을 `user_id`로 하지 않았다면 어떻게 해야 하나? 이때는 관계를 설정할 때, `return $this->hasMany('App\Post', 'custom_field_name');`, `return $this->belongsTo('App\User', 'custom_foreign_key');` 처럼, 엘로퀀트에게 컬럼 이름을 알려 주어야 한다. --- - [목록으로 돌아가기](../readme.md) - [17강 - 라라벨에 내장된 사용자 인증](17-authentication-201.md) - [19강 - 데이터 심기](19-seeder.md) ================================================ FILE: lessons/19-seeder.md ================================================ --- extends: _layouts.master section: content current_index: 19 --- # 19강 - 데이터 심기 앞에서 마이그레이션으로 테이블을 만들어 보았다. 만들어진 테이블에 테스트 데이터 또는 기본 데이터를 심는 과정을 씨딩(Seeding)이라 한다. 라라벨 5부터 Seeding을 효율적으로 하기 위한 Factory 기능이 제공된다. Factory는 데이터베이스 Seeding 뿐만 아니라, 테스트 클래스에서 필요한 데이터를 Stubbing하는데도 유용하게 사용할 수 있다. ## Model Factory database/factories/ModelFactory.php 를 살펴보자. `App\User` 모델에 대한 Factory는 기본 제공되는 것을 알 수 있다. `App\Post` 모델에 대한 Factory를 정의해 보자. ```php $factory->define(App\Post::class, function (Faker\Generator $faker) { return [ 'title' => $faker->sentence, 'body' => $faker->paragraph, 'user_id' => App\User::all()->random()->id ]; }); ``` `$faker`는 다양한 형태의 Dummy 데이터를 생성해 주는 클래스이다. 'user_id'를 생성할 때, `App\User::all()->random()->id`를 쓴 것을 주목해 보자. 아무리 테스트를 위한 데이터 심기라 하지만 실제 존재하는 User를 Post와 연결해야 하기 때문이다. **`참고`** php 5.5 이상에서는 `'App\Post'` 대신 `App\Post::class`와 같은 문법으로 사용할 수 있다. 문자열이 아니기 때문에 phpStorm 같은 IDE에서 해당 클래스로 이동할 때 편리하다(Cmd + Click). 팅커링 해 보자. ```bash $ php artisan tinker >>> factory('App\User')->make(); => App\User {#707 name: "Louvenia McDermott Sr.", email: "filiberto.moore@gmail.com", } >>> factory('App\User', 2)->make(); # Instance 2개 생성 => Illuminate\Database\Eloquent\Collection {#700 all: [ App\User {#703 name: "Jany Ullrich", email: "skunde@friesen.org", }, App\User {#712 name: "Ms. Shanelle Heller III", email: "walker84@lebsack.com", }, ], } ``` **`참고`** `factory()` Helper 에서 호출한 `make()` 메소드는 메모리에 모델 인스턴스만 생성하는 반면, 곧 보게 될 `create()` 메소드는 모델 인스턴스를 생성하고 DB에 저장하는 일까지 한다. ## Seeder 클래스 tinker해 보았던 것을 이제 Seeder 클래스로 만들자. ```bash $ php artisan make:seed UsersTableSeeder $ php artisan make:seed PostsTableSeeder ``` `factory()` Helper Function을 이용해서 Seeder 클래스를 채우자. `truncate()` 메소드는 모델과 연결된 테이블 데이터를 깨끗이 지워주는 역할을 해 준다. ```php // database/seeds/UsersTableSeeder class UsersTableSeeder extends Seeder { public function run() { App\User::truncate(); factory('App\User', 10)->create(); } } // database/seeds/PostsTableSeeder class PostsTableSeeder extends Seeder { public function run() { App\Post::truncate(); factory('App\Post', 20)->create(); } } ``` Seeder 클래스가 완성되었으면 마스터 Seeder 클래스인, database/seeds/DatabaseSeeder.php 에 등록하자. `Model::ungard()`는 모든 모델에 대해 MassAssignment를 허용한다는 의미이다. ```php class DatabaseSeeder extends Seeder { public function run() { Model::unguard(); $this->call(UsersTableSeeder::class); $this->command->info('users table seeded'); $this->call(PostsTableSeeder::class); $this->command->info('posts table seeded'); Model::reguard(); } } ``` ## Seeding 하자. 이제 모든 준비가 완료되었으니, Seeding을 하자. ```bash $ php artisan db:seed ``` ## QueryException ```bash [Illuminate\Database\QueryException] SQLSTATE[42000]: Syntax error or access violation: 1701 Cannot truncate a table referenced in a foreign key constraint ... ``` 왜 발생했을까? `truncate()` 메소드 호출에서 `posts.user_id` 와 `users.id` 간의 Foreign Key 제약 때문에 테이블의 레코드 삭제가 불가해서 발생한 것이다. database/seeds/DatabaseSeeder.php에 아래 2 라인을 추가하고 Seeding을 다시 실행해 보자. ```php class DatabaseSeeder extends Seeder { public function run() { DB::statement('SET FOREIGN_KEY_CHECKS=0'); ... DB::statement('SET FOREIGN_KEY_CHECKS=1'); } } ``` 이제 될 것이다. ```bash $ php artisan db:seed ``` Seeding이 잘 되었는 지, DB 테이블을 확인해 보자. --- - [목록으로 돌아가기](../readme.md) - [18강 - 모델간 관계 맺기](18-eloquent-relationships.md) - [20강 - Eager 로딩](20-eager-loading.md) ================================================ FILE: lessons/20-1-pagination.md ================================================ --- extends: _layouts.master section: content current_index: 21 --- # 페이징 모델에 데이터가 많아지면 한번에 모든 레코드를 표시할 수가 없게 된다. 이때 필요한 것이 페이징이다. ## 페이징된 콜렉션 만들기 app/Http/routes.php 에 아래와 같이 Route를 써 보자. 모델에 대한 쿼리 끝에 `get()` 이나 `find()` 대신, `paginate()` 메소드를 체인하면 페이징을 위한 준비 완료! 인자는 한번에 반환할 Collection 갯수를 넣는다. 이 예제에서는 10개로 했다. ```php Route::get('posts', function() { $posts = App\Post::with('user')->paginate(10); return view('posts.index', compact('posts')); }); ``` ## 페이징된 결과 보기 라라벨 페이징은 [twitter bootstrap](http://getbootstrap.com/)과 완벽하게 호환된다. 확인을 위해 resources/views/master.blade.php 에 bootstrap 사용을 선언하자. ```html ... ``` 20강에서 사용하던 resources/views/posts/index.blade.php 를 그대로 사용하자. `@stop` 바로 전에 아래 코드를 추가하자. `paginate()` 메소드를 체임함으로써, `$posts` 객체는 페이징이 가능한 Paginator 인스턴스로 변경되었고 `render()` 메소드를 쓸 수 있게 된 것이다. 렌더링된 뷰에서 소스보기를 해서 어떤 코드가 추가되었는 지 확인해 보자. ```html ... @if($posts)
{!! $posts->render() !!}
@endif @stop ``` ![](./images/20-1-pagination-img-01.png) --- - [목록으로 돌아가기](../readme.md) - [20강 - Eager 로딩](20-eager-loading.md) - [21강 - 메일 보내기](21-mail.md) ================================================ FILE: lessons/20-eager-loading.md ================================================ --- extends: _layouts.master section: content current_index: 20 --- # 20강 - Eager 로딩 Eager 로딩은 N+1 쿼리 문제를 해결해 주는 방법이다. 겁먹을 필요 없다, 아주 간단하니까. ## N+1 쿼리 문제를 만들어 보자. "18강 모델 관계 맺기" Post와 User 모델을 그대로 사용하자. app/Http/routes.php 를 작성하자. ```php Route::get('posts', function() { $posts = App\Post::get(); return view('posts.index', compact('posts')); }); ``` resources/views/posts/index.blade.php를 만들자. "19강 데이터 심기" 에서 만든 Post 모델의 'title'과, Post 작성자의 'name'을 뷰에 뿌릴 것이다. `@forelse` 루프 안에서 `{{ $post->user->name }}` 와 같은 식으로 User 모델의 'name' 속성에 접근한 것을 확인하자. ```html @extends('master') @section('content')

List of Posts


    @forelse($posts as $post)
  • {{ $post->title }} by {{ $post->user->name }}
  • @empty

    There is no article!

    @endforelse
@stop ``` 서버를 부트업하고 'posts' Route를 방문해 보자. 잘 출력되었는데 이게 무슨 문제란 것인가? 자 여기서 실제 백그라운드에서 발생하는 쿼리를 살펴 보자. app/Http/routes.php 에 아래 코드를 넣고, 브라우저에서 페이지를 새로고침 해 보자. ```php DB::listen(function($sql, $bindings, $time){ var_dump($sql); }); ``` **`알림`** 라라벨 5.2를 사용한다면 위 코드에서 에러가 날 것이다. [여기를 참고](https://github.com/appkr/l5essential/issues/12)해서 알맞게 코드를 수정하자. 다음과 같은 쿼리를 볼 수 있다. 즉, `{{ $post->user->name }}` 에서 매번 쿼리를 하는 것이다. - `select * from posts` x 1건 - `select * from users where users.id = ? limit 1` x N건 ![](./images/20-eager-loading-img-01.png) ## Eager 로딩으로 N+1개의 쿼리를 2개로 만들어 보자. app/Http/routes.php 에서 엘로퀀트 쿼리를 아래와 같이 수정하고 브로우저에서 페이지를 새로고침해 보자. ```php Route::get('posts', function() { $posts = App\Post::with('user')->get(); return view('posts.index', compact('posts')); }); ``` 쿼리가 2개로 줄어든 것이 보이는가? - `select * from posts` x 1건 - `select * from users where users.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` x 1건 `with(string|array $relations)` 메소드는 항상 엘로퀀트 모델 바로 뒤, 다른 메소드를 체인하기 전에 써야 한다. 메소드의 인자는 테이블 이름이 아니라, 모델 클래스에서 정의한 관계를 나타내는 메소드 이름임을 잘 기억하자. ## Lazy Eager 로딩 가끔 엘로퀀트를 이용한 쿼리를 먼저 만들어 놓고, 나중에 관계를 로드해야 하는 경우가 발생할 수 있다. 예를 들면, 쿼리 하나를 여러 번 재사용할 경우, 앞에서는 Eager 로딩이 필요없었지만, 나중에 필요하게 되는 경우 등이 해당된다. 이때는 `load(string|array $relations)` 메소드를 이용할 수 있다. ```php Route::get('posts', function() { $posts = App\Post::get(); $posts->load('user'); return view('posts.index', compact('posts')); }); ``` --- - [목록으로 돌아가기](../readme.md) - [19강 - 데이터 심기](19-seeder.md) - [추가 - 페이징](20-1-pagination.md) ================================================ FILE: lessons/21-mail.md ================================================ --- extends: _layouts.master section: content current_index: 22 --- # 21강 - 메일 보내기 ## mailgun 서비스 가입하자 실습을 위해 라라벨에 기본 값으로 설정되어 있는 [Mailgun 서비스](http://www.mailgun.com/)에 가입하자. 월 1만통까지는 공짜라고 한다. 가입 후 반드시, 가입할 때 사용한 메일 계정으로 들어가서 활성화 링크를 눌러줘야 정상적으로 메일 발송이 가능하다. ## 메일 설정 config/mail.php 를 열어 보내는 사람 정보를 채우자. ```php 'from' => ['address' => 'john@example.com', 'name' => 'John Doe'], ``` .env 를 열어 메일을 설정해 보자. MIAL_ 로 시작하는 나머지 설정은 모두 지우자. ```bash MAIL_DRIVER=smtp MAIL_USERNAME=your_mailgun_login_email MAIL_PASSWORD=your_mailgun_login_password ``` ## 메일을 보내 보자. app/Http/routes.php 에 메일을 보내는 Route를 작성하자. 노파심에 다시 얘기하면, 학습을 위해 편의상 routes.php 에 작성하지만, 실전에서는 컨트롤러 (또는 서비스 로직)에 들어가야 하는 내용이다. ```php Route::get('mail', function() { $to = 'YOUR@EMAIL.ADDRESS'; $subject = 'Studying sending email in Laravel'; $data = [ 'title' => 'Hi there', 'body' => 'This is the body of an email message', 'user' => App\User::find(1) ]; return Mail::send('emails.welcome', $data, function($message) use($to, $subject) { $message->to($to)->subject($subject); }); }); ``` `send()` 메소는 3개의 인자를 받는다. 첫번째는 사용할 뷰, 두번 째는 뷰에서 바인딩 시킬 데이터, 세번째는 콜백이다. **`참고`** `send()` 대신 `queue()` 메소드를 사용하는 것이 편리하다. Queue 설정이 되어 있지 않으면, `send()`로 자동 폴백된다. resources/views/emails/welcome.blade.php 를 만들자. ```html

{{ $title }} {{ $user->name }}


{{ $body }}


Mail from {{ config('app.url') }}
``` 블레이드 문법을 통해서 `send()` 메소드를 통해 넘겨 받은 데이터들을 바인딩하는 것을 볼 수 있다. `config(string $key) (== Config::get(string $key))` 함수를 통해 config/**.php 에 위치한 설정 값을 읽을 수 있다. 브라우저를 열고 'mail' Route를 방문한 후, `$to`로 지정한 메일 계정으로 가서 이메일이 잘 왔나 확인해 보자. ![](./images/21-mail-img-01.png) ## 테스트 방법 메일이 잘 가는 지를 테스트하기 위해 매번 실제로 메일을 보내는 것은 여러가지로 좋지 않다. 라라벨에서는 메일 테스트를 위해 'log' 드라이버를 제공하며, 메일 발송 결과를 storage/logs/laravel.log 에서 확인할 수 있다. .env에서 수정할 수 있다. ```bash MAIL_DRIVER=log ``` 로그파일은 tail 명령을 이용하면 관찰하기 편리하다. ```bash $ tail -f ./storage/logs/laravel.log ``` **`참고`** 실전에서는 `Mail::queue()` 메소드를 한번 Wrapping 한 도메인 레이어용 메일러를 별도로 만들고, 컨트롤러의 생성자에 의존성 주입을 해서 사용한다. 또는 컨트롤러에서 이런 데이터로 메일을 보내줘라고 이벤트를 생성하고, 이벤트를 구독하는 클래스에서 별도의 프로세스로 메일을 보내는 작업을 하게 된다. --- - [목록으로 돌아가기](../readme.md) - [추가 - 페이징](20-1-pagination.md) - [22강 - 이벤트](22-events.md) ================================================ FILE: lessons/22-events.md ================================================ --- extends: _layouts.master section: content current_index: 23 --- # 22강 - 이벤트 라라벨 이벤트 시스템은 Observer 또는 PubSub 패턴을 구현할 수 있게 해 준다. 필자가 알고 있는 이벤트 방식 구조체의 잇점 몇가지는 아래와 같다. - 사용자에게 빠른 응답을 제공할 수 있다. (Non blocking I/O) - 리스너를 여러개 구현하면, 이벤트 하나로 여러가지 작업을 동시에 수행할 수 있다. ## 시나리오 어떤 목적인지 모르겠지만, 사용자가 로그인 하면 users 테이블에 last_login 필드에 로그인 시간을 업데이트한다고 가정하자. **`참고`** 이벤트는 주로 IO 가 수반되는 경우에 많이 사용된다. smtp 로 이메일을 보낸다든가, HTTP 클라이언트로 외부 서비스로 부터 데이터를 가져온다든가, 파일 시스템으로부터 큰 파일을 읽을 때 등이 대표적인 예이다. ## 마이그레이션 users 테이블에 last_login 필드를 추가하자. ```bash $ php artisan make:migration add_last_login_to_users_table ``` 마이그레이션 코드를 작성하자. ```php class AddLastLoginToUsersTable extends Migration { public function up() { Schema::table('users', function(Blueprint $table) { $table->timestamp('last_login')->nullable(); }); } public function down() { Schema::table('users', function(Blueprint $table) { $table->dropColumn('last_login'); }); } } ``` 마이그레이션을 실행하자. ```bash $ php artisan migrate ``` User 모델을 수정하자. `$dates` 속성에 'last_login' 필드를 추가함으로써, 'updated_at', 'created_at' 처럼 `Carbon\Carbon` 이 제공하는 다양한 메소드에 접근할 수 있다. ```php class User extends Model implements ... { protected $dates = ['last_login']; } ``` ## 구조체를 만들어 보자. 이번에도 app/Http/routes.php 를 사용할 것이다. 제 "16강 사용자 인증 기본기"에서 사용했던 내용을 좀 훔쳐오자. ```php Route::get('auth', function () { $credentials = [ 'email' => 'john@example.com', 'password' => 'password' ]; if (! Auth::attempt($credentials)) { return 'Incorrect username and password combination'; } Event::fire('user.login', [Auth::user()]); var_dump('Event fired and continue to next line...'); return; }); Event::listen('user.login', function($user) { var_dump('"user.log" event catched and passed data is:'); var_dump($user->toArray()); }); ``` tinker 로 로그인에 사용할 사용자를 만들자. ```bash $ php artisan tinker >>> App\User::create([ ... 'email' => 'john@example.com', ... 'name' => 'John Doe', ... 'password' => bcrypt('password') ... ]); => App\User {#684 email: "john@example.com", name: "John Doe", updated_at: "2015-11-10 14:17:48", created_at: "2015-11-10 14:17:48", id: 11, } ``` 서버를 부트업하고 브라우저에서 'auth' Route 로 접근해 보자. `Event::fire(string|object $event, mixed $payload)` 에서 이벤트를 던지고, `Event::listen(string|array $events, mixed $listener)`이 이벤트를 받아서 처리하는 식의 구조체이다. `Event::fire()` 대신 `event()` Helper Function을 이용할 수도 있다. ![](./images/22-events-img-01.png) ## 이벤트를 처리하자. 구조체가 만들어 졌으니, 시나리오 대로 로그인 시각을 저장하자. ```php Event::listen('user.login', function($user) { $user->last_login = (new DateTime)->format('Y-m-d H:i:s'); return $user->save(); }); ``` artisan CLI 로도 확인해 보자. 'last_login' 필드가 `Carbon\Carbon` 인스턴스로 잘 동작하는 것을 확인할 수 있다. ```bash $ php artisan tinker >>> $user = App\User::find(11); => App\User {#672 id: 11, name: "John Doe", email: "john@example.com", created_at: "2015-11-10 14:17:48", updated_at: "2015-11-10 14:24:36", last_login: "2015-11-10 14:24:36", } >>> $user->last_login; => Carbon\Carbon {#679 +"date": "2015-11-10 14:24:36.000000", +"timezone_type": 3, +"timezone": "UTC", } ``` ## 라라벨 공식 문서에서의 이벤트 처음 접하는 분들이 보기에 라라벨 공식 문서의 이벤트 설명은 정말 어렵다. 하지만, 기본적으로 위 예제와 같은 동작을 좀 더 관리하기 편리하도록 쪼개 놓은 것이라고 볼 수 있다. 공식 문서의 `event(new PodcastWasPurchased($podcast))` 에서 이벤트 이름과 이벤트 데이터를 객체로 넘긴 것이며, app/Proviers/EventServiceProvider.php 에서 이벤트 이름과 이벤트 핸들러를 연결시켜 준 것이다. 연결된 이벤트 핸들러는 결국은 우리 예제의 `Event::listen()` 에 두번 째 인자로 전달한 Closure를 별도 클래스로 떼 놓은 것이다. --- - [목록으로 돌아가기](../readme.md) - [21강 - 메일 보내기](21-mail.md) - [23강 - 입력 값 유효성 검사](23-validation.md) ================================================ FILE: lessons/23-validation.md ================================================ --- extends: _layouts.master section: content current_index: 24 --- # 23강 - 입력 값 유효성 검사 항상 듣는 말이다. **"사용자가 입력한 값은 절대 신뢰하지 마라."** 유효성 검사는 사용자의 악의적인 해킹 시도 또는 잘못된 데이터 입력으로 부터 서비스를 보호하는 기본 중에 기본이므로 굉장히 중요하다. 프론트엔드에서 자바스크립트로 한번 걸렀다고 해도, HTTP 클라이언트를 통해서 직접 요청할 수 있으므로 백엔드에서도 반드시 유효성 검사를 수행해야 한다. ## 라라벨이 지원하는 유효성 검사 규칙 이 예제에서 사용하는 `required`, `min` 뿐 아니라, `string`, `confirmed`, `exists`, `required_if`, 등등등 굉장히 많다. [공식 문서](http://laravel.com/docs/validation#available-validation-rules)를 참고하자. ## 유효성 검사 레이어를 만들어 보자. app/Http/routes.php 를 이용하자. 여러 번 말하지만 학습 목적이며, 실제로는 컨트롤러에 작성되어야 한다. ```php Route::post('posts', function(\Illuminate\Http\Request $request) { $rule = [ 'title' => ['required'], // == 'title' => 'required' 'body' => ['required', 'min:10'] // == 'body' => 'required|min:10' ]; $validator = Validator::make($request->all(), $rule); if ($validator->fails()) { return redirect('posts/create')->withErrors($validator)->withInput(); } return 'Valid & proceed to next job ~'; }); Route::get('posts/create', function() { return view('posts.create'); }); ``` `Validator::make(array $formData, array $rule)` 메소드로 `Validator` 인스턴스를 만든다. 첫번 째 인자는 폼 데이터인데, 예제에서는 `$request` 인스턴스를 주입하고 `$request->all()` 로 접근했다. 두번 째 인자는 유효성 규칙이다. `Validator` 인스턴스에 대해 `fails()` 메소드로 if 테스트를 수행하고, 유효성 검사를 통과하지 못했을 경우, 'posts/create' Route로 Redirect 시켰다. 이때, `withErrors($validator)` 로 Sesson 에 에러 데이터를 굽는다. 또, `withInput()`으로 Session에 방금 넘겨 받은 폼 데이터를 'posts/create' Route로 돌아갔을 때도 쓸 수 있도록 한다. **`참고`** `$request` 인스턴스 주입 대신, `Request::input(string $key)/Input::get(string $key)`, `Request::all()/Input::all()` 과 같이 라라벨 Facade를 이용할 수도 있다. ## 폼을 만들자. resources/views/posts/create.blade.php 를 만들어 보자. ```html @extends('master') @section('content')

New Post


{!! $errors->first('title', ':message') !!}
{!! $errors->first('body', ':message') !!}
@stop ``` 이 폼에서는 `{!! $errors->first('title', ':message') !!}` 와 같이 블레이드 문법으로 POST 'posts' Route에서 넘겨 받은 `$errors` 인스턴스에 접근하고 있다. `$errors->all()`로 전체 에러 메시지백을 받은 후, 루프를 돌면서 에러를 뿌리는 방법도 있다. `$errors` 변수는 모든 뷰에서 항상 존재하기 때문에 `if (isset($errors))` 등과 같은 방어 조치를 할 필요가 없다는 것을 기억해 두자. `$errors`는 `withErrors()`에 의해 세션에 구워진 값이다. `{{ old(string $key) }}` Helper Function 을 사용한 것도 주의 깊게 보자. `old()`는 이전 입력 값이 Session에 없으면 '' 를 반환하고, 값이 있으면 반환한다. `withInput()`에 의해 세션에 구워진 값이다. ## 테스트 해 보자. 브라우저에서 'posts/create' Route를 열자. New Post 입력 폼이 열렸을 것이다. Title 입력박스에만 'First Article'이라고만 입력하고, '폼 제출' 버튼을 눌러 보자. 'post/create' `$validator->fails()` 블럭에서 'posts/create' Route로 Redirect되면서, Body 텍스트 영역 옆에 에러 메시지가 출력되었을 것이다. Title에 방금 입력했던 값이 폼에 입력되어 있는 것도 확인하자. **`참고`** `create.blade.php` 에서 `{{ var_dump(Session::all()) }}`을 넣고, 폼 전송을 다시 해 보자. ## 다양한 폼 유효성 검사 방법 [공식문서](http://laravel.com/docs/validation)를 보면 크게 3가지 유효성 검사 방법을 설명하고 있다. 1. `Validator` 인스턴스를 직접 만드는 방법 2. 컨트롤러에서 기본으로 사용할 수 잇는 `validate()` 메소드를 이용하는 방법 3. `FormRequest`를 이용하는 방법 1 번은 우리 예제에서 이미 다루었다. 2 번은 모든 컨트롤러가 `App\Http\Controllers\Controller` 라는 베이스 컨트롤러를 상속하고 있고, 베이스 컨트롤러가 포함하고 있는 `ValidatesRequests` 트레이트에 정의된 `validate()` 메소드를 이용하는 방법이다. 사용법은 1번과 거의 유사하다. 메소드의 첫 번째 인자로, 1번 방법에서는 `Request::all()` 배열을 넘긴 반면, 이 방법에서는 `Illuminate\Http\Request` 인스턴스 자체를 넘기면 된다. 많이 하는 질문 중에 하나가 유효성 검사 규칙을 어디에 두어야 하냐는 것인데, 정답은 없다. 컨트롤러에 두어도 되는데, 보통 모델 클래스에 Static 속성으로 정의하는 것을 많이 보았다. 아래 Post 모델과 컨트롤러의 예제를 보자. ``` // app/Http/routes.php Route::resource('posts', 'PostsController'); // app/Post.php class Post extends Model { public static $rules = [ 'title' => ['required'], 'body' => ['required', 'min:10'] ]; } // app/Http/Controllers/PostsController.php class PostsController extends Controller { public function create() { return view('posts.create'); } public function store(Request $request) { $this->validate($request, \App\Post::$rules); return '[' . __METHOD__ . '] ' . 'validate the form data from the create form and create a new instance'; } } ``` 3 번 Form Request를 이용하는 방법이 가장 깔끔하긴 하다. [공식 문서](http://laravel.com/docs/validation#form-request-validation)를 참고하자. --- - [목록으로 돌아가기](../readme.md) - [22강 - 이벤트](22-events.md) - [24강 - 예외 처리](24-exception-handling.md) ================================================ FILE: lessons/24-exception-handling.md ================================================ --- extends: _layouts.master section: content current_index: 25 --- # 24강 - 예외 처리 ## 예외 던지기 라라벨에서 예외(Exception)는 컨트롤러 또는 서비스 로직을 수행하는 도중 어디서든 던질 수 있다. app/Http/routes.php를 이용하여, 예외를 던지는 방법은 실습해 보자. ```php Route::get('/', function() { throw new Exception('Some bad thing happened'); }); ``` '/' Route로 접근해서 Whoops 페이지를 확인해 보자. `throw new My\Name\Space\CustomException('message');` 처럼 자신만의 Exception 클래스를 만들어서 던질 수도 있다. **`참고`** Production 환경에서는 보안을 위해 Stack Trace가 모두 찍히는 DEBUG 옵션을 꺼야 한다. .env 파일에서 `APP_ENV=production`, `APP_DEBUG=false`로 바꾼 후 '/' Route를 다시 방문해 보자. 웹 서버를 재시작해야 변경 내용을 확인할 수 있다. 또 다른 방법은 `abort(404)` 처럼, `abort(int $code, string $message)` Helper Function을 이용하는 방법이다. `abort()`는 `HttpException` 을 던진다. ## 예외를 캐치하자. 라라벨에서는 `try{} catch($e){}` 로 싸지 않아도, 글로벌 하게 예외를 잡아준다. 바로 app/Exceptions/Handler.php 가 주인공이다. 클래스를 살펴보자. ```php class Handler extends ExceptionHandler { protected $dontReport = []; public function report(Exception $e) {} public function render($request, Exception $e) {} } ``` `$dontReport` 속성에는 `report()` 메소드를 타지 않을 예외 클래스들의 리스트를 정의한다. `report()` 메소드는 기본적으로 로그 (storage/logs/laravel.log)에 예외의 내용을 쓴다. 여기서 관리자 이메일, 슬랙, BugSnag와 같은 외부 서비스에 예외 내용 등을 리포트하는 로직을 더 구현해 넣을 수 있다. `render()` 메소드에서는 예외를 Http 응답으로 렌더링하는 로직들을 포함한다. ## 예외를 처리하자. app/Exceptions/Handler.php의 `render()` 메소드에 아래 내용을 추가해 보자. `ModelNotFoundException` 이 발생하면 지정된 뷰를 내용으로 하는 HTTP 404 응답을 반환하라는 의미이다. ```php class Handler extends ExceptionHandler { public function render($request, Exception $e) { if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { return response(view('errors.notice', [ 'title' => 'Page Not Found', 'description' => 'Sorry, the page or resource you are trying to view does not exist.' ]), 404); } return parent::render($request, $e); } } ``` `Handler@render()` 메소드에서 뷰로 리턴할 resources/views/errors/notice.blade.php 뷰를 만들자. ```html {{ $title }}

{{ $title }}

{{ $description }}

``` routes.php 에서 `ModelNotFoundException`을 발생시키자. 해당 예외는 엘로퀀트 모델에 존재하지 않는 인스턴스를 쿼리했을 때 발생한다. 브라우저에서 '/' Route를 방문하여 렌더링 된 결과를 확인하자. ```php Route::get('/', function() { return App\Post::findOrFail(100); }); ``` ![](./images/24-exception-handling-img-01.png) --- - [목록으로 돌아가기](../readme.md) - [23강 - 입력 값 유효성 검사](23-validation.md) - [25강 - 컴포저](25-composer.md) ================================================ FILE: lessons/25-composer.md ================================================ --- extends: _layouts.master section: content current_index: 26 --- # 25강 - 컴포저 2강에서 라라벨 5 처음 설치할 때 Composer를 설치했을 것이다. 그땐 무엇인지 모르고 마냥 썼을 수도 있지만, 이제 그 정체를 조금만 핥아 보자. Composer는 php의 패키지 매니저이다. 패키지 레지스트리는 [패키지스트](https://packagist.org/)라 불린다. Java에 Maven, Python에 PyPi, Ruby에 Gem, Node에 Npm이 있다면, php엔 Composer가 있다. 라라벨도 버전 4로 넘어가면서 Composer를 본격적을 도입하고, 코어 프레임웍과 외부 패키지로 분리했다. 작은 서비스를 개발할 때는 패키지를 관리하는 일이 필요없지만, 서비스가 커질 수록 패키지 관리의 필요성은 급증한다. 급기야 **패키지를 관리하지 않아서, 개발자들이 패키지에 의해서 관리 당해지는 웃지 못할 사태**가 벌어질 수 있다. 패키지 매니저를 쓸 때 개인적으로 좋은 점을 정리해 봤다. - 기억력을 보조한다 (레지스트리 역할). - 이미 만들어진 바퀴를 쉽게 가져다 쓰고, 모양을 약간 바꾸어 쓸 수도 있다 (Code Reuse). - 외부 패키지들은 버전 관리에서 빠지기 때문에 코드 풋프린트가 줄어들고 Git에 푸시 할 때 빠르다. - 외부 패키지들의 업데이트를 자동화할 수 있다. 즉, 일일이 확인하고, 다운로드하고, 설치해 줄 필요 없다. ## 시나리오 어느 날 고객사에서 기존에 개발한 블로그 서비스에 Markdown 기능을 추가해 달라는 요청을 했다고 가정해 보자. 구글링을 통해 여러 패키지들을 검토해 본 후 여러 CMS들이 쓰고 있다는 말에 낚여서 `erusev/parsedown-extra` 을 쓰기로 했다고 가정하자. **`참고`** Markdown 은 과거의 위키 문법 처럼 빠른 글쓰기는 지원하는 초경량 마크업 도구이다. 지금 보는 이 문서들이 모두 Markdown 으로 작성되었고, Github가 컴파일해 주는 것이다. ## 패키지를 설치해 보자. ```bash $ composer require "erusev/parsedown-extra: 0.7.*" # 버전 없이 쓸 때는 따옴표를 안쳐도 된다. # 버전 앞에 ':' 대신 '='를 쓸 수도 있다. ``` 꽤 오랜 시간이 걸릴 것이다 :(. 프로젝트 디렉토리에 위치한 composer.json을 열어서 `require` 섹션에 엔트리가 정상적으로 업데이트된 것을 확인하자. ```json { "...": "...", "require": { "php": ">=5.5.9", "laravel/framework": "5.1.*", "erusev/parsedown-extra": " 0.7.*" }, "...": "...", } ``` **`참고`** 프로젝트 디렉토리에 위치한 composer.json 파일을 열고, `require` 섹션에 필요한 패키지를 직접 추가해 준 후, `$ composer update` 코맨드를 실행하는 방법으로도 설치할 수 있다. **`참고`** 라라벨 전용으로 만들어진 패키지의 경우에는 설치 후, Service Provider, Facade, 설정 파일 발행 등 해당 패키지 사용을 더 편리하게 사용하기 위한 추가적인 작업이 필요할 수 있다. 해당 패키지의 Github 또는 Packagist 페이지의 설명을 참조하자. **`참고`** 0.7.*은 0.7로 시작하는 버전 중 가장 최신 버전이란 의미이다. 모든 필드와 규칙을 설명할 수 없으므로, [공식 문서](https://getcomposer.org/doc/) 또는 XE팀에서 번역한 [한글본](https://xpressengine.github.io/Composer-korean-docs/)을 참조하자. ## 패키지를 이용해 보자. app/Http/routes.php 를 이용하자. ```php Route::get('/', function() { $text =<<text($text); }); ``` 서버를 부트업하고 '/' Route를 방문해 보자. Html로 잘 포맷팅 된 문서를 보고 있으면 성공한 것이다. ![](./images/25-composer-img-01.png) 입력 문자열로 긴 스트림을 쓰기 위해서 [Heredoc](http://php.net/manual/kr/language.types.string.php#language.types.string.syntax.heredoc) 문법을 사용하였다. 실전에서는 POST 로 넘겨 받은 폼 데이터의, 가령 'body' 필드 값일 것이며, `Input::get('body')/Request::input('body')`로 값을 얻어 올 수 있을 것이다. 설치한 `erusev/parsedown`의 [Github 페이지](https://github.com/erusev/parsedown)를 참고하여 사용법을 익혔다. 이 패키지는 `namespace`를 쓰지 않아 Root 네임스페이스에 존재한다. 인스턴스를 생성하고, `text()` 메소드에 컴파일할 문자열들을 넘겨 주었다. 실전에서는 컴파일 된 결과를 뷰의 데이터로 바인딩하거나, `markdown()`과 같은 커스텀 Helper Function을 만들어 뷰에서 직접사용하면 된다. 여기서 주목할 만한 것은, `app()` 이란 Helper Function의 등장이다. 이는 `new ParsedownExtra()`와 같은 역할을 한다고 보면 된다. `app()` 을 쓰는 것이 `new` 키워드를 쓰는 것 보다 더 좋은 점은 `ParsedownExtra` 클래스의 생성자에 주입되는 다른 인스턴스가 있다면(의존성 주입), `app()`의 경우 자동으로 주입을 해 준다. 다시 설명하면, `new` 키워드를 썼을 때에 꼭 해야 하는, 아래 예와 같이 복잡한 의존성 주입이 필요없다는 얘기다. 이는 라라벨의 Service Container 의 기능인데, 이 코스의 범위를 벗어난다 생각되므로, [공식 문서](http://laravel.com/docs/container)를 참조하기 바란다. ```php $sb = new SkateBoard(new Roller(new Wheel, new Joint), new Plate); $sb->run(); ``` ## 어디에 어떤 패키지가 있는 지 어떻게 알아요? 유용성, 인기도, 완성도 측면에서 검증되어 큐레이션된 패키지 들이 있는 곳을 소개한다. 필요한 기능이 있다면 직접 만들려 하지 말고, 이 목록을 탐색하고 구글링해 보자. - [ziadoz/awesome-php](https://github.com/ziadoz/awesome-php) - [chiraggude/awesome-laravel](https://github.com/chiraggude/awesome-laravel) - [TimothyDJones/awesome-laravel](https://github.com/TimothyDJones/awesome-laravel) > **`참고`** 컴포저를 사용하다 보면, 아래와 같은 메시지를 만나는 경우가 있다. Github 에 정해진 시간당 요청할 수 있는 한도를 초과했다는 의미인데, 메시지를 자세히 보면 답이 있다. 제시한 URL 로 이동하여 토큰을 만들고, 복사하여 'Token(hidden):' 에 붙여 넣으면 끗! > Could not fetch https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4, please create a GitHub OAuth token to go over the API rate limit. > Head to https://github.com/settings/tokens/new?scopes=repo&description=xxx to retrieve a token. It will be stored in "/home/vagrant/.composer/auth.json" for future use by Composer. > Token (hidden): --- - [목록으로 돌아가기](../readme.md) - [24강 - 예외 처리](24-exception-handling.md) ================================================ FILE: lessons/26-document-model.md ================================================ --- extends: _layouts.master section: content current_index: 27 --- # 실전 프로젝트 1 - Markdown Viewer 라라벨 공식 문서와 유사하게 왼쪽 사이드바에 문서 목록, 오른쪽 본문 영역에 HTML 로 컴파일된 문서가 표시되는 헝태로 최종 목표이미지를 잡아 보자. 이 실전 프로젝트를 통해 25강까지 배운 기본기 외에 Filesystem, Custom Helper, Cache, Elixir 등을 더 사용해 보게될 것이다. ## 26강 - Document 모델 이 강좌의 마크다운 문서들은 docs 폴더에 위치하고 있다. 생각해 보면, DB 테이블에서 데이터를 가져오는 것이 아니라, 파일시스템에서 가져와야 한다. 파일시스템에 접근하는 방법을 알아 보자. ### File vs Storage 라라벨에서 `File`과 `Storage` 2개의 Facade가 있다. 공식문서에서 `File`이 빠진 것으로 보아 `Storage`의 사용을 권장하는 것처럼 보인다. 두 Facade의 API(== public 메소드)는 거의 동일하지만, `Storage`는 파일 저장위치를 config/filesystems.php 에 지정해 놓으면 그 디렉토리 밖을 벗어날 수 없다. 우리의 docs 폴더는 프로젝트 루트에 위치하므로, `File` Facade를 이용해야 한다. **`참고`** `put(string $path, string $contents)` 파일 쓰기, `files(string $directory)` 파일 목록 가져오기, `glob(string $pattern)` 패턴에 맞는 파일 목록 가져오기, `isDirectory(string $directory)` 디렉토리 체크, `makeDirectory(string $path, int $mode)` 디렉토리 만들기 등은 실전에서 자주 사용하게 되니 사용법을 익혀 두자. ### Document 모델을 만들자. ```bash $ php artisan make:model Document ``` Document 모델은 엘로퀀트를 상속하지 않는다. 잘 생각해 보면, 컨트롤러의 기본 동작인 CRUD(Create, Read, Update, Delete) 중 Read만, 즉, 컨트롤러에서 요청한 이름에 해당하는 파일을 잘 읽어서 반환해 주는 메소드 하나만 필요하다. ```php getPath($file))) { abort(404, 'File not exist'); } return File::get($this->getPath($file)); } private function getPath($file) { return base_path($this->directory . DIRECTORY_SEPARATOR . $file); } } ``` `getPath()`란 메소드를 먼저 보자. `base_path()`는 프로젝트 루트 디렉토리의 절대 경로를 반환하는 Helper이다. 추가 경로를 인자를 넣으면 덧붙여서 반환해 준다. ```bash $ php artisan tinker >>> base_path(); => "/Users/Juwon/workspace/myProject" >>> base_path('docs/file.ext'); => "/Users/Juwon/workspace/myProject/docs/file.ext" ``` **`참고`** DIRECTORY_SEPARATOR 상수는 윈도우즈 시스템에서는 `\` *nix 에서는 `/`를 반환한다. `File::exists(string $path)` 로 인자로 넘어온 파일이 존재하지 않으면, `abort()` Helper로 404 NotFoundHttpException 을 던지도록 했다. if 테스트를 통과하면, 인자로 받은 마크다운 파일의 내용을 읽어서 반환한다. 인자로 넘겨 받은 `$file` 값이 없을 경우를 대비해, index.md 를 기본값으로 지정했다. ### 동작 테스트 app/Http/routes.php 에서 동작 테스트를 해 보자. [25강 - 컴포저](25-composer.md)에서 사용한 내용을 조금만 수정해서 사용할 것이다. ```php Route::get('docs/{file?}', function($file = null) { $text = (new App\Document)->get($file); return app(ParsedownExtra::class)->text($text); }); ``` 이전에 보지 못했던 `'docs/{file?}'` 엔드포인트가 먼저 눈에 띈다. 여기서 `file`을 'Route 파라미터'라고 한다. 파라미터는 중괄호로 싼다. 올드스쿨식으로 표현하자면 docs?file= 과 같다고 보면 된다. 파라미터로 받은 `$file`을 바로 뒤 콜백에 인자로 넘긴 것이 보일 것이다. 물음표는 `file` 파라미터가 있을 수도 있고 없을 수도 있다는 의미이다. 즉, docs, docs/any-text 를 모두 이 Route에서 처리한다는 의미이다. 25강에 `$text` 변수의 값을 Heredoc 으로 하드코드로 넣어 주었다면, 여기서는 Document 모델의 `get()` 메소드 요청으로 부터 받아왔다. 인스턴스 생성과 메소드 호출을 인라인으로 한 줄에 표현하기 위해 () 문법을 이용하였다. 서버를 부트업하고 테스트해 보자. ![](./images/26-document-model-img-01.png) ### 예외 처리 `Document` 모델에서 `abort(404)`를 던진 것에 대한 예외처리를 하자. [24강 - 예외 처리](24-exception-handling.md)에서 배운 app/Exceptions/Handler.php 에다, `or $e instanceof NotFoundHttpException` 을 추가해 주자. ```php public function render($request, Exception $e) { if ($e instanceof ModelNotFoundException or $e instanceof NotFoundHttpException) { return response(view('errors.notice', [ 'title' => 'Page Not Found', 'description' => 'Sorry, the page or resource you are trying to view does not exist.' ]), 404); } return parent::render($request, $e); } ``` ![](./images/26-document-model-img-02.png) --- - [목록으로 돌아가기](../readme.md) - [27강 - Document 컨트롤러](27-document-controller.md) ================================================ FILE: lessons/27-document-controller.md ================================================ --- extends: _layouts.master section: content current_index: 28 --- # 실전 프로젝트 1 - Markdown Viewer ## 27강 - Document 컨트롤러 이번 강좌에서는 routes.php 에서 콜백으로 정의했던 내용을 컨트롤러로 옮기고, ParsedownExtra도 좀 더 편하게 사용할 수 있도록 커스텀 Helper Function을 만들어 보자. ### Custom Helper 라라벨 내장 Helper Function 처럼, 여기저기서 편하게 마크다운 컴파일러를 불러 쓸 수 있도록 하기 위해, app/helpers.php 를 만들자. ```php function markdown($text) { return app(ParsedownExtra::class)->text($text); } ``` 라라벨이 부트업될 때 helpers.php 파일도 포함시키기 위해, composer.json에 files 엔트리를 추가하자. ```json "autoload": { "classmap": ["..."], "files": ["app/helpers.php"], "psr-4": {"...": "..."}, }, ``` autoload 파일을 리프레시하기 위해 아래 코맨드를 수행하자. ```bash $ composer dump-autoload # autoload가 잘 되었는지 tinker 로 테스트해 보자 $ php artisan tinker >>> markdown('**bold**'); => "

bold

" ``` ### Document 컨트롤러를 만들자. ```bash $ php artisan make:controller DocumentsController ``` 잘 생각해 보면, DocumentsController도 마크다운으로 컴파일된 내용을 데이터로 하고 뷰를 리턴하는 `show()` 메소드 하나면 충분하다. ```php document = $document; } public function show($file = null) { return view('documents.index', [ 'index' => markdown($this->document->get()), 'content' => markdown($this->document->get($file ?: '01-welcome.md')) ]); } } ``` 생성자(Constructor)에서 `App\Document` 인스턴스를 주입(Dependency Injection)했다. 뷰에 2개의 데이터를 바인딩하는데 `$index`는 왼쪽 사이드 바에 보여줄 목록이며, `$content`는 본문이다. ### Route를 정의하자. Route 정의가 훨씬 간단해 졌다. [14강 - 이름 있는 Route](14-named-routes.md)에서 배운 것 처럼, Route 이름은 필수이다. ```php Route::get('docs/{file?}', [ 'as' => 'documents.show', 'uses' => 'DocumentsController@show' ]); ``` ### 뷰를 만들자. resources/views/documents/index.blade.php 를 만들자. ```html @extends('master') @section('content')
{!! $content !!}
@stop ``` 서버를 띄우고 뷰에 잘 뿌려지는지 확인해 보자. ![](./images/27-document-controller-img-01.png) 기본 기능은 이걸로 완료되었다. 다음 강의에서는 백엔드의 부하 감소를 위해 Cache 기능을 활용하는 것을 배우도록 하자. 좀 더 나이스하게 보이기 위해서 CSS 작업을 할 것인데, 빌드를 위해 Elixir도 사용해 볼 것이다. --- - [목록으로 돌아가기](../readme.md) - [26강 - Document 모델](26-document-model.md) - [28강 - Cache](28-cache.md) ================================================ FILE: lessons/28-cache.md ================================================ --- extends: _layouts.master section: content current_index: 29 --- # 실전 프로젝트 1 - Markdown Viewer ## 28강 - Cache 우리가 사용한 마크다운 파일은 자주 변경되지 않는다. 즉, 매번 사용자가 요청할 때 마다 모델에서 요청한 파일을 읽어 들이고, ParsedownExtra 클래스 인스턴스를 만들어서 `text()` 메소드를 호출한다는 것은 서버의 CPU 및 메모리 자원뿐만아니라 사용자의 시간을 낭비하는 일이다. 이럴 때 필요한 것이 서버측 캐시이다. 캐시는 HTTP 요청에 대한 응답을 파일 또는 메모리에 저장해 두었다가 두번째 요청부터는 저장소에서 바로 꺼내어 주는 기능이다. 데이터베이스 드라이버와 마찬가지로 라라벨 캐시도 다양한 드라이버를 지원한다. ### 캐시 설정 .env 와 config/cache.php를 열어서 기본 캐시 드라이버 설정을 확인하자. `file` 드라이버를 사용하고 있고, 캐시 저장소는 storage/framework/cache 인 것을 알 수 있다. 우선 실습을 위해 `file` 드라이버를 그냥 사용하도록 하자. ```bash CACHE_DRIVER=file ``` ```php '...' => '...', 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache'), ], ``` ### 컨트롤러에 캐시 기능 추가 `DocumentsController::show()` 메소드를 수정할 것이다. 캐시는 `key => value` 저장소라는 것을 기억하자. ```php public function show($file = '01-welcome.md') { $index = \Cache::remember('documents.index', 120, function () { return markdown($this->document->get()); }); $content = \Cache::remember("documents.{$file}", 120, function() use ($file) { return markdown($this->document->get($file)); }); return view('documents.index', compact('index', 'content')); } ``` `Cache` Facade 를 이용한다. `remember()` 메소드의 첫번 째 인자는 키 이름이다. 이 키 이름으로 데이터를 저장할 것이다. 두번째 인자는 캐시를 유지할 시간이다. 키 이름에 해당하는 값이 캐시 저장소에 없으면, 세번째 인자로 받은 콜백을 실행한다. 이 콜백에서 반환되는 값을 캐시 키 값으로 해서, 두번째 인자로 지정된 시간만큼 캐시 저장소에 가지고 있는다. 지정된 시간 내에 들어온 요청에 대해서는 세번째 인자인 콜백을 실행하지 않고, 캐시 저장소에서 키 값에 대응되는 값(value)를 찾은 후 반환하는 식이다. 이 예제에서는 키 이름을 `document.index` 와 `documents.문서이름`으로 지정했고, 첫 캐시가 적재된 이후 2시간 동안은 캐시에서 바로 응답하도록 했다. 가령, 8시 정각에 첫 요청이 들어와 새로운 캐시가 생성되었다면, 10시까지는 캐시에서 응답을 하다가, 10시 1초 이후에 요청이 들어오면 다시 콜백을 수행하고 캐시 저장소에 키와 값을 적재하게 된다. (무슨 얘긴지 이해 되었기를 바라는 마음에서 반복해서 설명했다.) **`참고`** 앞 강의에서 계속 Closure라는 용어를 썼다. 이번 강의에서는 콜백이란 용어를 썼다. 엄격하게 다르지만, 지금은 같은 용어라고 생각하자. **`참고`** "어떨 때는 클래스 앞에 `\` 를 쓰고 어떨 때는 안 쓰고 왜 그래요?" "이번에는 일부러 쓴 것이다." `\`는 루트 네임스페이스를 의미한다. 우리 컨트롤러는 `App\Http\Controllers` 네임스페이스 아래에 위치한다. 컴퓨터에서 절대경로와 상대 경로를 얘기할 때랑 마찬가지로, `Cache`라고 그냥 쓰면, `App\Http\Controllers\Cache`를 의미하게 되고(상대 경로), 그런 클래스는 존재하지 않는다. 해서 루트 (`\`) 네이스페이스에 존재하는 `Cache` 라고 명시적으로 쓴 것이다. 클래스를 시작하기 전에 `use` 키워드로 전체 경로를 표시해 주면 `\`를 생략할 수 있다. (이는 네임스페이스가 있는 모든 언어 마찬가지다. Java의 경우 `use` 대신 `import`를 사용한다. `import org.apache.http.client.HttpClient` 처럼.) ### 실험해 볼까? 서버를 띄우고, '/docs' Route를 처음으로 방문해 보자. 그리고, storage/framework/cache 아래에 캐시 파일이 생성된 것을 확인해 보자. 또, 'docs' Route를 다시 방문해 봐도, 우리 예제가 무겁지 않아서 별로 빨라진 것은 못 느낄 것이다. `Document::show()` 메소드에 `dd('reached')` 와 같은 디버그 코드를 넣고 다시 요청해 보자. 클라이언트 측의 요청이 캐시에 의해 바로 응답되고, `Document` 모델에 도달하지 않을 것을 알 수 있을 것이다. `docs/01-welcome.md` 를 수정하고 재요청해봐도 수정된 내용은 표시되지 않을 것이다, 2시간 동안은... ### 그럼, 2시간 내에 내용이 변경되면 어떻게 하지? 일단은 artisan CLI를 이용해 수동으로 지워 보자. 변경이 생기면 이벤트를 던져서 캐시를 초기화하는 것은 뒤에 다시 배운다. ```bash $ php artisan cache:clear ``` ### (OPTIONAL) Memcached 파일 캐시도 훌륭하다. 하지만 실전에서는 memcache나 redis와 같은 인 메모리(in-memory) 캐시를 주로 사용한다. 여기서는 memcached를 사용할 것이다. .env 를 수정하자. ```bash CACHE_DRIVER=memcached ``` 먼저 이전에 사용했던 file 캐시를 삭제하고 memcached 를 설치하고 실행하자. ```bash $ rm -rf storage/framework/cache/* $ brew search memcache $ brew install homebrew/php/php56-memcached # 자신의 php 버전에 맞는 모듈을 설치하자. # 설치 종료 후 콘솔에 표시된 memcached 시작 코맨드를 잘 살펴보자. # 필자의 장치에는 이미 설치되어 있어, 알고 있던 코맨드를 남긴다. $ memcached -u memcached -d -m 30 -l 127.0.0.1 -p 11211 # 본 강좌랑 무관하므로 사용법은 구글링으로 찾아 보시길.. ``` file 캐시에서 했던 방법과 동일한 방법으로 캐시 기능이 잘 동작하는 지 확인해 보자. 설정 값이 변경되었으므로 테스트 전에 `$ php artisan serve` 를 재기동하는 것을 잊지 말고. **`참고`** 서버 측 뿐만 아니라, 클라이언트(브라우저) 측에서도 캐싱을 한다. 라라벨과 무관하므로 설명하지 않는다. --- - [목록으로 돌아가기](../readme.md) - [27강 - Document 컨트롤러](27-document-controller.md) - [29강 - Elixir, 만병통치약?](29-elixir.md) ================================================ FILE: lessons/29-elixir.md ================================================ --- extends: _layouts.master section: content current_index: 30 --- # 실전 프로젝트 1 - Markdown Viewer ## 29강 - Elixir, 만병통치약? **`필독`** 프론트엔드에 관심이 없다면 이 강좌를 읽을 필요 없다. Elixir('엘릭서'라 읽음)은 만병통치약을 뜻하는 단어이다. 라라벨의 Elixir란 이름은 어디에서 유래되었는 지 모르겠다. Elixir는 여러 기능을 가지고 있지만, 함축하자면 프론트엔드 리소스, 그러니까 CSS 와 Javascript 같은 리소스의 빌드를 자동화하는 도구라고 생각할 수 있다. 여기서 말하는 빌드란 Minification, CSS Vendor Prefixing, 여러 파일 병합, Sass/Less/Coffee/Babel 스크립트의 컴파일, ... 등을 의미한다. 프론트엔드 빌드 자동화를 수행하는 구현체는 Ruby on Rails 에서 Asset Pipeline 이란 이름으로 세상에 먼저 소개되었고, 이후 Django Pipeline 등 여러 프레임웍에서 따라한 것으로 필자는 알고 있다. 라라벨도 그중 하나!! 라라벨 5 버전이 출시되고 Elixir 가 공개되기 이전에 개발자들은 [Gulp](http://gulpjs.com/)나 [Grunt](http://gruntjs.com/)를 이용하여 빌드 자동화 스크립트를 작성해 왔다. 하지만 라라벨 5의 Elixir는 기존 빌드 스크립트들의 복잡함과 어려움을 극도로 단순화하여, 초보자도 쉽게 사용할 수 있는 API를 제시하고 있다. (그럼에도 불구하고, 여전히 어렵고 복잡하다.) ### 필요한 프론트엔드 리소스 정의 우리 프로젝트에 필요한 프론트엔드 리소스는 무엇인가? 앞선 강좌를 통해 우린 이미 Twitter Bootstrap을 가져다 쓴 바 있다. 이번 강좌에서는 CDN 버전을 그대로 사용하지 않고, 우리 서비스의 로컬 리소스로 추가할 것이다. 그 외 아이콘을 쓰기 위한 FontAwesome, 앞으로 계속 진행될 학습을 위해 이 프로젝트 전용 스타일시트와 자바스크립트 파일을 만들 것이다. 강좌를 진행하면서 이 목록에 더 많은 프론트엔드 리소스들이 추가될 것이다. - Twitter Bootstrap - FontAwesome - resources/assets/sass/app.scss - resources/assets/js/app.js 이번 강좌를 통해, 위에 나열된 2개의 외부 의존성과 내가 만든 2개의 리소스에 대한 빌드 자동화 방법을 배워볼 것이다. ### 필요한 글로벌 툴들 설치 먼저 [nodejs](https://nodejs.org)가 필요하다. 공식 사이트를 방문한 후, 인스톨러를 다운로드 받아 클릭-클릭으로 설치할 수 있다. 이 글을 쓰는 시점에 4.2.2 LTS 버전과 5.0 두 개의 버전이 존재하는데, 4.2.2 LTS를 받자. 인스톨러를 통해 node 런타임과 npm (node package manager)가 동시에 설치된다. ```bash $ node --version # v4.2.2 $ npm --version # 2.14.7 ``` Elixir는 Gulp 툴의 Wrapper이다. Gulp에 의존한다는 얘기~ 고로, 설치하자. 개발 머신 전체에서 `gulp`란 명령을 쓸 수 있도록 글로벌로(`-g`)로 설치한다. ```bash $ sudo npm install -g gulp $ gulp --version # 3.9.0 ``` 3rd Party(남이 만든 것이란 의미. 내껀 1st Party라 한다) 스타일시트와 자바스크립트 리소스를 관리하기 위해 Bower 툴을 설치하자. ```bash $ sudo npm install -g bower $ bower --version # 1.6.5 ``` 여기까지 작업은 다른 프로젝트에서도 계속 사용하게 될 글로벌 패키지들의 설치로, 최초 딱 한번만 수행하면 된다. 이 다음부터 설명하는 것은 매 프로젝트마다 해 주어야 하는 설치 작업이다. ### Elixir 의존 패키지 설치 및 최초 실행 Elixir가 의존하는 node 패키지들을 설치하자. 설치할 패키지들은 package.json 파일에 정의되어 있으며, 라라벨 프로젝트가 생성될 때 같이 생성되었다. 아래 작업은 라라벨에서 `composer install` 했던 것과 동일한 작업이라 생각하면 된다. ```bash $ npm install ``` node_modules 폴더 아래에 뭔가 잔뜩 설치되었을 것이다. Elixir가 잘 동작하는지 확인해 보자. 아래 코맨드를 수행하기 전에 public/css 디렉토리가 있는지 확인해 보자. 없을 것이다. 코맨드 실행 후 다시 확인해 보자. ```bash $ gulp ``` ### Bower 패키지 설치 php/composer.json, nodejs/package.json 처럼 Bower 용 패키지들을 담을 레지스트리 파일인, bower.json 을 만들자. 또, .bowerrc 파일로 Bower 패키지들의 설치 위치도 변경하자 (디폴트는 프로젝트 루트의 bower_components). 또, .gitignore 파일에 Bower 패키지 저장소가 버전 컨트롤에서 제외되도록 정의해 놓자. ```bash $ touch bower.json && echo '{"name":"myProject"}' > bower.json # bower.json 파일을 만들고, JSON 오브젝트를 써 놓는다. $ touch .bowerrc && echo '{"directory":"resources/assets/vendor","analytics":false}' > .bowerrc $ echo '/resources/assets/vendor' >> .gitignore # >> 은 기존 파일에 내용 append의 의미다. ``` Bower 패키지를 설치하자. `--save-dev` 는 bower.json 파일에 devDependencies 로 써 놓는다는 의미이다. 버전 컨트롤에서 제외시켰는데, bower.json에 의존성을 써 놓았기 때문에, 다른 개발자가 프로젝트의 버전 컨트롤 저장소를 clone/pull 했을 때 `$ bower install` 코맨드로 의존성을 설치할 수 있다. 이 강좌에서는 scss 문법을 이용할 것이라, `bootstrap-sass` 패키지를 설치했다. resources/assets/vendor 디렉토리에 설치한 패키지들이 있는지 확인해 보자. ```bash $ bower install bootstrap-sass --save-dev $ bower install font-awesome --save-dev ``` ### 휴~ 이제 빌드 스크립트를 쓰자. `$ gulp` 명령에 반응하는 스크립트는 프로젝트 루트에 위치한 gulpfile.js 이다. 아래는 sass 컴파일하고, 자바스크립트들을 머지한 후, 파일 이름에 버전을 매기고, fonts 파일을 정해진 위치에 배포하는 gulpfile.js 스크립트이다. ```javascript var elixir = require('laravel-elixir'); elixir(function (mix) { mix .sass('app.scss') .scripts([ '../vendor/jquery/dist/jquery.js', '../vendor/bootstrap-sass/assets/javascripts/bootstrap.js', 'app.js' ], 'public/js/app.js') .version([ 'css/app.css', 'js/app.js' ]) .copy("resources/assets/vendor/font-awesome/fonts", "public/build/fonts"); }); ``` Elixir의 Javascript API 들은 대부분 관례로 정해진 상대 경로를 기준으로 하므로, [문서](http://laravel.com/docs/elixir)를 잘 살펴보고 이용해야 한다. 필자가 아는 관례들은 아래에 정리해 두었다. API|Base Directory|Description ---|---|--- `less(src, output, options)`|resources/assets/less|less 문법으로 써진 스타일시트를 css로 컴파일 `sass(src, output, options)`|resources/assets/sass|sass, scss 문법으로 써진 스타일시트를 css로 컴파일 `styles(styles, output, baseDir)`|resources/assets/css|css 문법으로 써진 파일을 읽어서 머지하고 public/css로 배포 `coffee(src, output, options)`|resources/assets/coffee|coffee 문법으로 써진 자바스크립트를 js로 컴파일 `babel(scripts, output, baseDir, options)`|resources/assets/babel|ES6 또는 7 문법으로 써진 자바스크립트를 js로 컴파일 `scripts(scripts, output, baseDir)`|resources/assets/js|js 파일을 읽어서 머지하고 public/js로 배포 `copy(src, output)`|프로젝트 루트|`src` 파일/디렉토리를 `output`으로 복사 `version(src, buildPath)`|public|`src`로 넘겨 받은 css, js 파일을 버전을 부여하여 public/build/css, public/build/js 디렉토리로 배포 Bootstrap과 FontAwesome의 스타일시트들은 `styles()` API를 이용하지 않고, app.scss에서 import 하였다. 이 과정과 무관하므로 app.scss에 작성된 내용들은 설명하지 않으므로, 소스코드를 살펴 보기 바란다. ```css @import "../vendor/bootstrap-sass/assets/stylesheets/bootstrap"; @import "../vendor/font-awesome/scss/font-awesome"; ... ``` ### Version & Cache Busting 브라우저 입장에서 보면 app.css 파일은 정적(static) 파일이다. 즉, 캐싱 기능이 있는 브라우저에서, 웹 서버에 요청을 해서 304 Not Modified 응답을 받으면, 브라우저는 로컬 캐시 저장소에 가지고 있던 파일을 사용한다. 웹 서버는 바보라 정적 파일의 변경을 눈치채지 못할 수 있다. 그래서 개발측에서 수정한 파일의 이름이 같으면, 캐시 정책이 만료되기 전에는, 웹 서버는 app.css 파일에 대한 브라우저의 요청에 계속 304로만 답하게 된다. 변경된 프론트엔드용 정적 리소스가 서비스에 바로 반영될 수 있도록 하는 테크닉이 Cache Busting의 핵심이다. 과거에는 app.css?t=1447665283 와 같이 랜덤 스트링을 붙여 브라우저가 매번 새로운 파일을 받아가도록 하기도 했었다. 라라벨 Elixir에서는 `version()` API를 이용해서 Cache Busting을 자동화 해 준다. 빌드된 파일들은 public/build 디렉토리에 저장되고, 뷰에서 `elixir()` Helper를 이용해서 사용할 수 있다. `$ gulp` 명령을 실행할 때 마다, public/build 아래의 .js, .css 파일명이 변경되는 것을 확인할 수 있을 것이다. 빌드를 위해 생성된 중간 파일들은 필요 없으므로, 버전 컨트롤에서 제외하자. ```bash $ echo /public/css >> .gitignore $ echo /public/js >> .gitignore ``` Cache Busting을 사용할 수 있도록 resources/views/master.blade.php 를 수정해 보자. `{{ elixir('css/app.css') }}`, `{{ elixir('js/app.js') }}`를 주목해 보자. `elixir()` Helper는 public/build/rev.manifest.json 의 내용을 읽어서 가장 최신으로 빌드된 리소스의 파일명을 쓸 수 있도록 하는 기능을 한다. ```html Laravel 5 Essential @yield('style')
@yield('content') @include('footer')
@yield('script') ``` ### 췍! 이제 카드를 까보자. resources/assets/sass/app.scss, resources/views/docs/index.md를 더 수정했다. 거듭말하지만, 프론트엔트 코스가 아니므로 자세한 설명은 하지 않으니, 코드를 참조하기 바란다. ![](./images/29-elixir-img-01.png) **`참고`** 28강에서 우리가 캐시 기능을 추가했던 것을 기억하는가? 스타일시트나 뷰를 변경한 후, `$ php artisan cache:clear`를 해서 기존 캐시를 지워주어야 변경된 내용을 확인할 수 있다. 개발기간 중에는 불편할 수 있으니, 컨트롤러에서 캐시 기능을 주석처리해 놓는 것도 좋다. 다만, 버전 컨트롤에 업로드하기 전에 반드시 주석을 풀어줘야 한다. **`참고`** Elixir 에는 liveReload 유사 기능을 내장하고 있다. 파일이 변경되면 브라우저에 바로 반영해 주는 기능이다. `browserSync()` 기능을 사용하려면, [공식문서](http://laravel.com/docs/elixir#browser-sync)를 확인해 보자. ### production 빌드 css vendor prefix, css minify, js uglify 까지 하면, production 환경에 적합한 프론트엔드 리소스를 빌드할 수 있다. `--production` 옵션을 이용하자. ```bash $ gulp --production ``` ![](./images/29-elixir-img-02.png) --- - [목록으로 돌아가기](../readme.md) - [28강 - Cache](28-cache.md) - [30강 - Debug & Final Touch](30-final-touch.md) ================================================ FILE: lessons/30-final-touch.md ================================================ --- extends: _layouts.master section: content current_index: 31 --- # 실전 프로젝트 1 - Markdown Viewer ## 30강 - Debug & Final Touch **`필독`** 프론트엔드에 관심이 없더라도, 1번과 2번은 백엔드 내용이므로 보도록 하자. 29강까지 작성한 우리 프로젝트에는 3가지 문제가 있다. 1. 이미지가 정상적으로 표시되지 않는다. 2. 브라우저의 이미지 캐시가 동작하지 않는다. 3. 코드 블럭에 Syntax Highlight가 없다. ### 이미지 표시하기 우리 이미지는 public 디렉토리 밖에, 그러니까 public 과 같은 레벨인 docs 디렉토리에 위치하고 있다. 즉 웹 서버가 접근할 수 없는 위치에 있다는 것이다. 이럴 경우에는 Route 에서 이미지 파일에 대한 요청을 받아서, 컨트롤러에서 DocumentRoot(==public) 바깥 쪽에 위치한 이미지를 찾아 정확한 Content-Type 으로 응답해 주어야 한다. app/Http/routes.php 에 이미지 요청을 컨트롤러와 연결시키는 Route를 작성하자. 기존 문서 요청과 Route가 거의 동일하여, 이를 분리시키기 위하여 라라벨 Route의 글로벌 패턴이란 기능을 사용하였다. `Route::pattern()`의 첫번째 인자는 Route Parameter 이름, 두번째 인자는 정규표현식이다. 이 예제의 정규표현식은 01-welcome-image-01.png 에서는 매칭이 발생하고, 01-welcome.md 에서는 매칭이 발생하기 않는다. 해서, `Route::get('docs/{image}')`에서는 이미지 파일만 응답하고, `Route::get('docs/{file?}')` 에서는 나머지 docs로 시작하는 모든 경우에 대해 응답하게 된다. ```php Route::pattern('image', '(?P[0-9]{2}-[\pL-\pN\._-]+)-(?Pimg-[0-9]{2}.png)'); Route::get('docs/{image}', [ 'as' => 'documents.image', 'uses' => 'DocumentsController@image' ]); Route::get('docs/{file?}', [ 'as' => 'documents.show', 'uses' => 'DocumentsController@show' ]); ``` **`참고`** Route Parameter에 대한 패턴이, 가령 `{image}`에 대한 패턴이 여러군데 쓰이면 `Route::pattern()`과 같이 글로벌 패턴을 사용한다. 여러 곳에 쓰지 않고 특정 Route 에서만 사용된다면, `Route::get('docs/{image}', '...')->where('image', 'pattern')` 처럼 사용할 수도 있다. **`참고`** 정규표현식의 작성 및 테스트를 위해서는 [Regex101](https://regex101.com/) 사이트를 이용할 것을 추천한다. 필자가 본 정규표현식 툴 중 최고이다. 이미지 응답을 만들기 위해서 intervention/image 패키지를 이용할 것이다. composer 로 설치하고, [매뉴얼](http://image.intervention.io/getting_started/installation#laravel)에 따라 config/app.php 파일에 서비스프로바이더, Facade도 추가하자. ```bash $ composer require "intervention/image:2.3.*" ``` ```php //config/app.php return [ ..., 'providers' => [ ..., Intervention\Image\ImageServiceProvider::class, ], 'aliases' => [ ..., 'Image' => Intervention\Image\Facades\Image::class, ] ]; ``` 이제 Image Facade를 이용할 수 있으니, Document 모델과 DocumentsController 를 수정하자. Document 모델에서 중복 제거를 위해 여러군데 refactoring 된 것을 확인할 수 있다. ```php // app/Document.php directory = $directory; } public function get($file = 'index.md') { return File::get($this->getPath($file)); } public function image($file) { return Image::make($this->getPath($file)); } private function getPath($file) { $path = base_path($this->directory . DIRECTORY_SEPARATOR . $file); if (! File::exists($path)) { abort(404, 'File not exist'); } return $path; } } ``` `image()` 메소드에서 인자로 넘겨 받은 파일을 찾아 `\Intervention\Image\Image` 인스턴스를 리턴한다. ```php // app/Http/Controllers/DocumentsController.php document = $document; } public function show($file = '01-welcome.md') { ... } public function image($file) { $image = $this->document->image($file); return response($image->encode('png'), 200, [ 'Content-Type' => 'image/png' ]); } } ``` `image()` 메소드에서 `Content-Type`을 png 타입으로 지정하고, 모델로 부터 넘겨 받은 이미지 인스턴스를 컨텐츠로 해서 HTTP 응답을 하고 있다. ![](./images/30-final-touch-img-01.png) ### 브라우저 캐시 살리기 intervention/image 를 이용해서 만든 이미지 응답은 웹 서버가 접근할 수 있는 DocumentRoot(== public)에 있는 리소스로 만들어진 것이 아니다. 정해진 Route 규칙에 따라 요청하면 서버에서 이미지를 DocumentRoot 밖에서 찾아 반응하는 이미지 응답이기 때문에, 웹 서버가 브라우저 캐싱에 관여할 수 없다. 해서 수동으로 브라우저 캐싱 기능을 살려 주어야 한다. 구현을 위해 웹서버와 브라우저간의 캐싱 메카니즘을 이해해야 한다. 브라우저가 리소스(이 예제에서는 이미지) 요청을 할 때, 캐시에 요청할 URL 과 연결된 캐시가 있는 지 확인하고, 키 값을 얻어온다. 이 키 값은 이전 동일 URL 요청에서 서버가 Etag 헤더 값으로 응답한 것이다. 얻어온 키 값은 If-Non-Match 헤더의 값으로 지정하고 서버에 리소스를 요청한다. 요청을 받은 서버는 If-Non-Match 헤더의 값과 URL 에 해당하는 서버의 Etag 로직에 의해 생성된 값을 비교하여, 같으면 304 Not Modified를, 다르면 200 OK와 함께 요청한 리소스를 HTTP 바디로 해서 응답한다. 아래 그림을 보자. ![](http://4.bp.blogspot.com/-Oj6tyOt8ag0/VY4EkXx2OZI/AAAAAAAAAEI/7UJnCP4Y8OE/s640/etags.png) *`Image Source` ["Etags and browser cache, June 26 2015"](http://thespringthing.blogspot.kr/2015/06/etags-and-browser-cache.html)* 먼저 Etag를 만드는 로직을 Document 모델에 구현하자. `md5()` php 내장 해시 함수를 사용하였다. Etag 값은 고유하기만 하면 어떤 문자열을 사용해도 상관없다. ```php class Document { ... public function etag($file) { return md5($file . '/' . File::lastModified($this->getPath($file))); } ... } ``` DocumentsController 에서 요청으로 받은 Etag값과 Document 모델에서 생성한 Etag 값과 비교하여 같으면 304, 같지 않으면 200을 응답하는 로직을 만들어보자. ```php class DocumentsController extends Controller { ... public function image($file) { $image = $this->document->image($file); $reqEtag = Request::getEtags(); $genEtag = $this->document->etag($file); if (isset($reqEtag[0])) { if ($reqEtag[0] === $genEtag) { return response('', 304); } } return response($image->encode('png'), 200, [ 'Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=0', 'Etag' => $genEtag, ]); } } ``` ![](./images/30-final-touch-img-03.png) ### Syntax Highlight 적용하기 Syntax Highlight를 위한 Bower 콤포넌트를 설치하자! ```bash $ bower install google-code-prettify --save-dev ``` gulpfile.js에 방금 다운로드 받은 패키지를 추가하자. ```javascript elixir(function (mix) { mix .sass('...') .scripts([ '...', '../vendor/google-code-prettify/src/run_prettify.js', 'app.js' ], 'public/js/app.js') .... }); } ``` 해당 패키지에 대한 [사용법은 여기](https://code.google.com/p/google-code-prettify/wiki/GettingStarted)서 익혔다. 마크다운 컴파일러는 코드블럭을 `
` 태그로 컴파일 한다. 따라서, `
` 태그에 방금 설치한 google-code-prettify의 스타일시트가 반응하도록 클래스를 추가해 줄 것이다. 마크다운 문서에 직접 추가할 수 없으니, resources/assets/js/app.js에 jQuery를 이용해 동적으로 추가해 보자.

```javascript
$("pre").addClass("prettyprint");
```

gulp 커맨드로 다시 빌드하고, 캐시도 지운 후 Syntax Highlight가 작 적용되었는지 확인해 보자.

```bash
$ gulp --production
$ php artisan cache:clear
```

![](./images/30-final-touch-img-02.png)


---

- [목록으로 돌아가기](../readme.md)
- [29강 - Elixir](29-elixir.md)



================================================
FILE: lessons/31-forum-features.md
================================================
---
extends: _layouts.master
section: content
current_index: 32
---

# 실전 프로젝트 2 - Forum

댓글이 가능한 간단한 포럼을 구현해 본다. 이를 통해 라라벨의 Request & Response 라이프싸이클에 대한 이해를 높인다. 뿐만 아니라, CRUD, Event, File/Image Upload, 인증과 권한부여 등에 대해 배워볼 예정이다.

## 31강 - 포럼 요구사항 기획

[StackOverflow](http://stackoverflow.com/) 또는 [라라캐스트 포럼](https://laracasts.com/discuss)을 머릿 속에 떠올리며 어떤 기능들이 필요할지?, 최종 결과물은 어떤 모습을 하고 있을 지?, 개발해야 할 항목들을 하나씩 정리해 보자. 일단 쭈욱~ 나열하고, 강좌 진행 상황에 따라 개발 항목을 삭제하거나, 추가하도록 하자.
 
-    **사용자 인증 및 역할 기반 접근 제어**
    -    네이티브 인증
    -    소셜 인증
    -    admin, member 역할에 따른 접근 제어
-    **고품질의 레이아웃/UI**
    -    메뉴, 사용자 등록 폼, 로그인 폼
    -    Gravatar로 사용자 사진을 보여준다.
    -    모바일에 대응한다.
    -    영어, 한글 2가지 언어를 지원한다.
-    **포럼**
    -    포럼 생성, 포럼 목록, 포럼 상세 보기, 포럼 수정, 포럼 삭제
    -    권한이 있는 사용자만 포럼을 생성할 수 있다.
    -    관리자 또는 포럼 소유자만이 포럼을 수정 또는 삭제 할 수 있다.
    -    파일 또는 이미지를 첨부할 수 있다.
    -    마크다운 문법을 지원한다. 작성 중 프리뷰 기능을 쓸 수 있다.
    -    태그 기능을 지원한다.
    -    필터 기능을 지원한다.
    -    포럼 생성시 지정된 사용자에게 이메일을 발송한다.
    -    포럼 생성시 댓글에 대한 알림 기능을 토글할 수 있다.
    -    "답변됨" 기능을 지원한다.
    -    Full Text Search 기능을 지원한다.
-    **댓글**
    -    댓글의 댓글 기능을 지원한다.
    -    포럼과 댓글은 one to many 관계를 가진다. (포럼 삭제시 댓글 삭제)
    -    권한이 있는 사용자만 댓글을 생성할 수 있다.
    -    관리자 또는 댓글 소유자만이 댓글을 수정 또는 삭제할 수 있다.
    -    마크다운 문법을 지원한다.
    -    포럼 생성자가 알림을 켜 놓은 경우, 댓글 생성시 연결된 포럼 생성자에게 이메일을 발송한다.
-    **포럼/댓글 공통**
    -    성능 향상을 위해 서버 사이드 캐싱을 이용한다.


---

- [목록으로 돌아가기](../readme.md)
- [32강 - 사용자 로그인](32-login.md)



================================================
FILE: lessons/32-login.md
================================================
---
extends: _layouts.master
section: content
current_index: 33
---

# 실전 프로젝트 2 - Forum

실습을 진행하기 전에 기존에 만들었던 파일 중, 쓰지 않을 파일이나, 쓰지 않을 코드 블럭들을 삭제할 것을 권장한다. 정리하지 않아도 무방하긴 하지만...

## 32강 - 사용자 로그인

기본기 16강, 17강에서 배운 내용을 기반으로 사용자 로그인 기능을 만들어 보자. 여러개로 쪼개기도 뭣 하고... 그리고, 진도를 좀 많이 빼기 위해, 이번 강좌에서 욕심을 좀 냈으니 지치지 말고 따라해 주기 바란다.

### Route 정의

app/Http/routes.php에 사용자 등록, 로그인/아웃, 비밀번호 초기화, 홈페이지, 로그인 후 이동할 페이지 등에 사용할 엔드포인트를 먼저 만들자. 서비스에 사용할 수 있을 만큼의 퀄리티를 내기 위해 이번 강좌부터는 코드량이 좀 많다.

```php
Route::get('/', [
    'as' => 'root',
    'uses' => 'WelcomeController@index'
]);

Route::get('home', [
    'as' => 'home',
    'uses' => 'WelcomeController@home'
]);

/* User Registration */
Route::group(['prefix' => 'auth', 'as' => 'user.'], function () {
    Route::get('register', [
        'as'   => 'create',
        'uses' => 'Auth\AuthController@getRegister'
    ]);
    Route::post('register', [
        'as'   => 'store',
        'uses' => 'Auth\AuthController@postRegister'
    ]);
});

/* Session */
Route::group(['prefix' => 'auth', 'as' => 'session.'], function () {
    Route::get('login', [
        'as'   => 'create',
        'uses' => 'Auth\AuthController@getLogin'
    ]);
    Route::post('login', [
        'as'   => 'store',
        'uses' => 'Auth\AuthController@postLogin'
    ]);
    Route::get('logout', [
        'as'   => 'destroy',
        'uses' => 'Auth\AuthController@getLogout'
    ]);
});

/* Password Reminder */
Route::group(['prefix' => 'password'], function () {
    Route::get('remind', [
        'as'   => 'reminder.create',
        'uses' => 'Auth\PasswordController@getEmail'
    ]);
    Route::post('remind', [
        'as'   => 'reminder.store',
        'uses' => 'Auth\PasswordController@postEmail'
    ]);
    Route::get('reset/{token}', [
        'as'   => 'reset.create',
        'uses' => 'Auth\PasswordController@getReset'
    ]);
    Route::post('reset', [
        'as'   => 'reset.store',
        'uses' => 'Auth\PasswordController@postReset'
    ]);
});
```

`Route::group()`은 여러개의 Route에 공통된 Prefix Url, Route Name을 붙이거나, 미들웨어를 동시에 적용할 때 사용할 수 있다. 여기에 사용한 모든 컨트롤러와 메소드는 라라벨 기본으로 내장되어 배포되는 `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController`의 것을 그대로 사용한 것이다.

app/Http/Controllers/WelcomeController.php 는 별도로 만들어 주고, Route 와 연결된 메소드를 써 주어야 한다.

```bash
$ php artisan make:controller WelcomeController
```

```php
middleware('auth', ['only' => ['home']]);
    }

    public function index()
    {
        return view('index');
    }

    public function home()
    {
        return view('home');
    }
}
```

16~17강에서 'auth' 미들웨어를 배운것을 떠올려 보자. `Route::get('url', ['middleware' => 'auth', ...]);` 식으로 썼을 것이다. Route 대신 컨트롤러에서 메소드별로 미들웨어를 적용할 수 있는데, 위 예와 같이 생성자 메소드에서 `$this->middleware('middleware-to-use')` 식으로 쓴다. 그리고, 두번째 인자로 `only` 키워드를 사용했는데, 지정된 메소드에서만 이 미들웨어를 적용하겠단 의미이다. 즉, 여기서는 `home()` 메소드에 접근하기전에 'auth' 미들웨어를 거쳐야 하고, 'auth' 미들웨어에 의해 로그인되어 있지 않을 경우, 'auth/login' Route 로 이동하게 된다. 

Route가 잘 정의되었는지 확인해 보자. 에러가 안났다는 것은 엔드포인트와 컨트롤러의 메소드가 잘 연결되었다는 의미이다. 

```bash
$ php artisan route:list
```

![](./images/32-login-img-01.png)

### 마스터 템플릿을 손보자!

먼저, 뷰 디렉토리를 좀 더 구조화 하기 위해, 기존의 master.blade.php 파일은 resources/views/layouts/master.blade.php 로 이동하였다.

좀 더 있어 보이는 레이아웃을 위해 [Bootstrap 사이트](http://getbootstrap.com/getting-started/)에서 괜찮은 템플릿을 좀 훔쳐와서, resources/views/layouts/master.blade.php 에 적용해 보았다. 구조화를 위해 navigation.blade.php, footer.blade.php 로 내용을 좀 나누었으니 코드를 살펴 보자.

내친 김에 플래시 메시지도 사용할 것이다. 플래시 메시지란 컨트롤러에서 세션에 구워 뷰에 전달할 메시지를 의미한다. 뷰에서는 `Session::get('key')` 로 값을 얻을 수 있다. 이 프로젝트에서는 laracasts/flash 패키지를 이용할 것이다.

```bash
$ composer require "laracasts/flash:1.3.*"
```

```php
// config/app.php
'providers' => [
    ...
    Laracasts\Flash\FlashServiceProvider::class,
],
'aliases' => [
    ...
    'Flash' => Laracasts\Flash\Flash::class,
],
```

```html





  
  
  
  

  Laravel 5 Essential

  
  @yield('style')

  
  



  @include('layouts.partial.navigation')

  @include('layouts.partial.flash_message')

  
@yield('content')
@include('layouts.partial.footer') @yield('script') ``` `@include` 로 하위 뷰들을 포함하고 있다. ``는 자바스크립트에서 XHR 요청을 할 때 사용하기 위해 포함시켜 놓은 것이다 ([공식 문서 참고](http://laravel.com/docs/routing#csrf-protection)). ```html ``` `@if(! auth()->check())` 로 로그인이 안되어 있으면 로그인과 사용자 등록 링크를, 로그인되어 있으면 메뉴들과 로그아웃 링크를 보여 주도록 뷰를 분기시키고 있다. ```html @if (session()->has('flash_notification.message')) @endif @if ($errors->has()) @endif ``` 첫번 째 블럭은 세션에 flash_notification으로 시작하는 값이 있으면 [Bootstrap CSS로 나이스하게 디자인된 Alert](http://getbootstrap.com/components/#alerts)를 보여 준다. 두번째 블럭은 23강 유효성 검사에서 배운 세션에 구워 놓은 `$errors` 값이 있으면 폼을 다시 한번 체크하라고 Alert를 띄워 준다. ```html ``` 다국어 지원할 것을 대비해 footer 영역에 미리 링크를 준비해 놓았다. resources/views/index.blade.php, resources/views/home.blade.php 뷰 파일들은 각자의 취향에 맞게 적절한 내용을 담아 만들도록 하자. ![](./images/32-login-img-02.png) ### 뷰를 만들자. 이제 사용자 등록, 로그인, 비밀번호 초기화 폼을 만들 것인데, `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController` 의 메소드들에 미리 정의된 뷰의 이름들을 잘 확인하고 뷰 파일을 만들자. 필자는 기본 내장된 컨트롤러 메소드 이름이 굉장히 헷갈려서 맘에 들지 않아, 아래 테이블로 정리해 보았다. Route|Route 이름|컨트롤러 메소드|연결된 뷰|뷰의 역할 ---|---|---|---|--- /|index|WelcomeController@index|index|인덱스 페이지 home|home|WelcomeController@home|home|로그인한 후 이동할 페이지 auth/register|user.create|AuthController@getRegister|auth.register|사용자 등록 폼 auth/login|session.create|AuthController@getLogin|auth.login|사용자 로그인 폼 auth/remind|reminder.create|PasswordController@getEmail|auth.password|비밀번호 초기화 링크 요청 이메일 발송 폼 | | |emails.password|비밀번호 초기화 링크를 담은 이메일 뷰 auth/reset/{token}|reset.create|PasswordController@getReset|auth.reset|비밀번호 초기화 폼 **`참고`** `getRegister()` 메소드는 `Illuminate\Foundation\Auth\RegistersUsers` trait에서, `getLogin()` 메소드는 `Illuminate\Foundation\Auth\AuthenticatesUsers` trait에서, `getEmail()` 와 `getReset()` 메소드는 `Illuminate\Foundation\Auth\ResetsPasswords` trait에서 각각 찾아야 한다. 이들 trait들을 `App\Http\Controllers\Auth\AuthController`, `App\Http\Controllers\Auth\PasswordController`가 use 키워드로 사용하고 있다. ```html @extends('layouts.master') @section('content')
{!! csrf_field() !!}
{!! $errors->first('name', ':message') !!}
{!! $errors->first('email', ':message') !!}
{!! $errors->first('password', ':message') !!}
{!! $errors->first('password_confirmation', ':message') !!}
@stop ``` 스샷에서 입력값 유지, 유효성 검사, 앞 절에서 설치한 플래시메시지 등 모든 기능이 동작하는 것을 확인할 수 있다. ![](./images/32-login-img-03.png) ```html @extends('layouts.master') @section('content')
{!! csrf_field() !!}
{!! $errors->first('email', ':message') !!}
{!! $errors->first('password', ':message')!!}

 

Not a member? Sign up

Remind my password

@stop ``` `` 의 값은 `Auth::attempt(array $credentials, bool $remember)` 메소드의 2번째 인자로 전달된다. 2번째 인자 없이 로그인하면 2시간동안 로그인 세션이 유지된다. 체크박스에 체크가 되어 `true` 값이 전달되면, 5년동안 로그인이 유지된다. ![](./images/32-login-img-04.png) ```html @extends('layouts.master') @section('content')
{!! csrf_field() !!}

Password Remind

Provide the same email address that you've registered and check your email inbox to reset the password.

{!! $errors->first('email', ':message') !!}
@stop ``` ![](./images/32-login-img-05.png) ```html Click here to reset your password: {{ route('reset.create', $token) }} ``` .env 파일에서 `MAIL_DRIVER=log`로 바꾸어 놓고, 비밀번호 초기화를 위한 이메일이 잘 나가는지 확인해 보았다. ![](./images/32-login-img-06.png) ```html @extends('layouts.master') @section('content')
{!! csrf_field() !!}

Reset Password

Provide your email address and NEW PASSWORD.

{!! $errors->first('email', ':message') !!}
{!! $errors->first('password', ':message') !!}
{!! $errors->first('password_confirmation', ':message') !!}
@stop ``` 비밀번호 초기화 이메일에서 받은 링크를 브라우저에 붙여 넣어 비밀번호를 초기화할 수 있다. ![](./images/32-login-img-07.png) 디자인을 위해 resources/assets/sass/app.scss 도 일부 내용이 변경되었다. 상세 설명은 생략하니, 코드를 참고하기 바란다. --- - [목록으로 돌아가기](../readme.md) - [31강 - 포럼 요구사항 기획](31-forum-features.md) - [33강 - 소셜 로그인](33-social-login.md) ================================================ FILE: lessons/32n33-auth-refactoring.md ================================================ --- extends: _layouts.master section: content current_index: 41 --- # 실전 프로젝트 2 - Forum ## 32/33 보충 - 인증 리팩토링 잠깐 쉬어가자~ 32강 33강에서 구현했던 라라벨 내장 인증 Route와 컨트롤러의 메소드가 맘에 들지 않아, 인증 레이어를 다시 구성하고, Integration Test 부분도 추가했다. 또, 앞 강의에서 발생한 버그들도 수정했다. ### 인증 재구현 라라벨 내장 인증 기능을 사용함에 있어서 Route 정의에 연결된 'AuthController'의 메소드들이 trait 와 프레임웍 속에 숨어 있어서, 읽기도 쉽지 않을 뿐더러, 필요에 의해서 내부를 수정하면, 다음 프레임웍 업데이트 때, 나의 수정 내용이 엎어쳐지는 불상사가 발생할 수 있다. 라라벨에 내장된 인증 구현에 대한 의존성을 버리기 위해서, - `App\Http\Controllers\UsersController` (가입) - `App\Http\Controllers\SessionsController` (로그인/아웃) - `App\Http\Controllers\SocialController` (소셜 로그인) - `App\Http\Controllers\PasswordsController` (비밀번호 재설정) 으로 각각 분리하여 작성하였다. #### 문제점 인식 서비스마다 로그인에 대한 정책이 있기 마련인데, 필자는 우리 서비스(이하 'myProject')의 사용자들이 소셜 로그인도 할 수 있고, 네이티브 로그인도 할 수 있게 하고 싶다. 여기서는 사용자의 Github 이메일 계정과, myProject 에 회원 가입한 이메일 계정이 동일할 경우만을 가정한다. **`참고`** Github 이메일과 myProject의 사용자 이메일 주소가 서로 다르다면, 두 개의 서로 다른 계정으로 인식될 것이다. 즉, myProject 에 들어와서 사용자가 생성하는 Article, Comment 등의 모델은 그때 그때 로그인한 소셜 또는 네이티브 사용자에게 속할 것이다. 프로그램적으로 해결할 수 있는 문제가 아니므로, 정책적으로 해결하거나, 사용자가 수동으로 계정을 연결할 수 있는 방법과 장치를 제공해야 할 것이다. 기존 소셜 로그인 코드를 잠시 살펴 보자. 아래 코드에서 `User::firstOrCreate()` 메소드에서 Github 로 부터 받은 정보를 이용하여 **'password'가 없는 사용자를 생성**하고 있다는 것을 기억하고 있자. ```php // 기존 코드 // app/Http/Controllers/Auth/AuthController.php public function handleProviderCallback() { $user = \Socialite::driver('github')->user(); $user = User::firstOrCreate([ 'name' => $user->getName(), 'email' => $user->getEmail(), ]); ... } ``` 아래와 같은 경우의 수가 발생할 수 있다. 1. myProject 에 회원 가입을 먼저하고, 소셜 로그인을 시도하는 경우 1. 사용자 이름까지 같을 경우 -> 로그인 됨 2. **사용자 이름이 다르면, 사용자 생성을 시도하게 됨 -> 동일 이메일을 가진 User 가 하나 더 생김** 2. 소셜 로그인을 하여, 빈 'password'를 가진 User 모델이 생성된 상태에서, 네이티브 인증을 시도하는 경우 1. 'password' 가 없으므로 네이티브 로그인 불가능 2. myProject 에 회원 가입을 시도하는 경우, **같은 이메일을 가진 User 가 이미 있으므로 가입 불가능** 다음과 같은 방법으로 해결해 보자. 1. myProject 에 회원 가입을 먼저하고, 소셜 로그인을 시도하는 경우 1. `firstOrCreate()` 부분을 'email'로 먼저 쿼리하고, 없으면 User를 생성하는 로직으로 다시 작성하자. 2. 소셜 로그인을 하여, 빈 'password'를 가진 User 모델이 생성된 상태에서... 1. myProject 에 회원 가입을 시도하는 경우, 기존 소셜 로그인으로 'password' 없이 생성된 계정에 'password'를 업데이트한다. 2. 비밀번호 재설정을 시도하는 경우, 소셜 로그인 사용자라고 안내한다. 우선, 기존 마이그레이션의 'password' 필드에 `nullable()` 속성을 추가하였다. 수정했으면 `$ php artisan migrate:refresh --seed` ```php // database/migrations/create_users_table.php public function up() { // ... $table->string('password', 60)->nullable(); } ``` #### Route 정의 ![](./images/32n33-auth-refactoring-img-01.png) 잘 보면, `Route::get('social/{provider}', 'SocialController@execute')` 부분에서 'social/github', 'social/facebook' 등으로 소셜 로그인 공급자를 더 붙일 수 있도록 Route 구조를 좀 변경할 것을 확인할 수 있다. ```php // app/Http/routes.php /* User Registration */ Route::get('auth/register', [ 'as' => 'users.create', 'uses' => 'UsersController@create' ]); Route::post('auth/register', [ 'as' => 'users.store', 'uses' => 'UsersController@store' ]); /* Social Login */ Route::get('social/{provider}', [ 'as' => 'social.login', 'uses' => 'SocialController@execute', ]); /* Session */ Route::get('auth/login', [ 'as' => 'sessions.create', 'uses' => 'SessionsController@create' ]); Route::post('auth/login', [ 'as' => 'sessions.store', 'uses' => 'SessionsController@store' ]); Route::get('auth/logout', [ 'as' => 'sessions.destroy', 'uses' => 'SessionsController@destroy' ]); /* Password Reminder */ Route::get('auth/remind', [ 'as' => 'remind.create', 'uses' => 'PasswordsController@getRemind', ]); Route::post('auth/remind', [ 'as' => 'remind.store', 'uses' => 'PasswordsController@postRemind', ]); Route::get('auth/reset/{token}', [ 'as' => 'reset.create', 'uses' => 'PasswordsController@getReset', ]); Route::post('auth/reset', [ 'as' => 'reset.store', 'uses' => 'PasswordsController@postReset', ]); ``` **`중요`** Route 정의가 변경되었으므로, 회원 가입, (소셜) 로그인, 비밀번호 재설정 관련해서 기존에 뷰 코드에 박아 놓았던, `route()` Helper 를 이용한 링크들을 모두 수정해 주어야 한다. **`참고`** 기존에 'resources/views/auth' 아래에 위치하던 뷰들도 'users', 'sessions', 'passwords' 아래로 옮기고, 이름도 적절히 변경하였다. 각 컨트롤러에서 `view()`를 반환할 때 바뀐 위치로 적용해 주어야 한다. #### 'SocialController(소셜 로그인)' 구현 앞 절의 Route 정의에서 `Route::get('social/{provider}', 'SocialController@execute')`로 썼다. 소셜 인증 과정에는 Github 'Authorize application' 페이지로 이동하는 Route 하나, 앞 과정에서 사용자가 승인하면 Github 에서 myProject 로 콜백해 주는 Route 총 두 개가 필요했던 것을 `execute()` 하나로 줄였다. `execute()` 메소드에서 myProject의 Router로 들어오는 HTTP 요청 쿼리스트링의 'code' 필드의 유무에 따라 분기시킨 것이다. 전체적인 과정은 아래 그림을 참조하자. ![](./images/32n33-auth-refactoring-img-02.png) `$user = (\App\User::whereEmail($user->getEmail())->first()) ?: \App\User::create([...]);` 부분에서 기존의 `firstOrCreate()` 메소드를 다시 썼다. 'email' 로만 쿼리해서 있으면 myProject 에 로그인해 주고, 해당 'email'을 가진 레코드가 없으면, 'email', 'name' 필드를 가진 사용자를 생성시키도록 수정하였다. ```php // app/Http/Controllers/SocialController.php class SocialController extends Controller { public function execute(Request $request, $provider) { if (! $request->has('code')) { return $this->redirectToProvider($provider); } return $this->handleProviderCallback($provider); } protected function redirectToProvider($provider) { // ...} protected function handleProviderCallback($provider) { $user = $this->socialite->driver($provider)->user(); $user = (\App\User::whereEmail($user->getEmail())->first()) ?: \App\User::create([ 'name' => $user->getName(), 'email' => $user->getEmail(), ]); // ... } } ``` Route 가 바뀌었으므로, [Github Developer Applications Console](https://github.com/settings/developers)를 방문하여 Authorization callback URL 을 'http://localhost:8000/social/github' 로 변경하여야 한다. **`참고`** `handleProviderCallback()` 에서 보통은 Github 에서 받은 사용자 정보로 폼을 포함한 뷰를 한번 더 사용자에게 보여주며, 다른 이메일, 비밀번호, 사용자 이름 등을 더 받아 `App\User` 모델로 저장하는 것이 정석이다. 이렇게 구현하면, 위에서 언급한 복잡한 Sync 과정이 불필요하다. #### UsersController(가입) 구현 기존 대비 특별히 달라진 부분들만 살펴보도록 하자. `store()` 메소드에서는, 먼저 `App\User` 모델에 새로 정의한 `noPassword() (== whereNull('password'))` 란 [쿼리스코프](http://laravel.com/docs/eloquent#query-scopes)를 이용하여 회원 가입 요청에서 사용자가 제출한 'email' 값과 일치하고, 'password' 가 `null` 인 사용자를 찾는다. 이는 소셜로 로그인하면서 생성된 계정을 의미한다. 소셜로 생성된 계정이면 `syncAccountInfo()` 메소드로 처리 로직을 위임하고, 소셜 로그인 이력이 없는 회원 가입 요청이면 `createAccount()` 메소드로 위임했다. `syncAccountInfo()` 메소드에서는, 사용자가 회원 가입 폼에 입력한 값들에 대한 유효성 검사를 수행하고, 통과하면 폼에서 넘겨 받은 'password' 값으로 기존에 `null` 이던 값을 대체하였다. Github 사용자 이름과, myProject 에서 사용하는 이름이 다를 수 있으므로, 'name' 필드도 넘겨 받은 값으로 업데이트하였다. 여기서 주목할 점은 `createAccount()` 메소드와 폼 데이터에 대한 유효성 검사 규칙이 약간 다르다는 것이다. 소셜 로그인으로 이미 사용자는 생성된 상태이므로, 'email' 필드 검사 규칙에서 'unique:users' 규칙이 빠졌다. `createAccount()` 는 기존과 크게 달라진 점이 없다. ```php // app/Http/Controllers/UsersController.php class UsersController extends Controller { public function store(Request $request) { if ($user = User::whereEmail($request->input('email'))->noPassword()->first()) { // Filter through the User model to find whether there is a social account // that has the same email address with the current request return $this->syncAccountInfo($request, $user); } return $this->createAccount($request); } protected function syncAccountInfo(Request $request, User $user) { $validator = \Validator::make($request->except('_token'), [ 'name' => 'required|max:255', 'email' => 'required|email|max:255', 'password' => 'required|confirmed|min:6', ]); if ($validator->fails()) { return back()->withInput()->withErrors($validator); } $user->update([ 'name' => $request->input('name'), 'password' => bcrypt($request->input('password')) ]); \Auth::login($user); flash(trans('auth.welcome', ['name' => $user->name])); return redirect(route('home')); } protected function createAccount(Request $request) {// ...} } ``` #### SessionsController(로그인/아웃) 구현 여기서는 기존 대비 크게 달라진 점이 없이, trait나 프레임웍 속에 숨어 있던 것을 도메인레이어로 옮겨 놓았을 뿐이므로, 설명을 생략한다. #### PasswordsController(비밀번호 재설정) 구현 myProject 에 가입하지 않고, 소셜 로그인으로만 사용하던 사용자가 갑자기 비밀번호 재설정 시도를 할 경우를 대비한 방어조치만 추가했다. 나머지 부분들은 기존 대비 크게 다르지 않다. ```php // app/Http/Controllers/PasswordsController.php public function postRemind(Request $request) { // ... if (User::whereEmail($request->input('email'))->noPassword()->first()) { flash()->errors(sprintf("%s %s", trans('auth.social_olny'), trans('auth.no_password'))); return back(); } //... } ``` #### 인증 관련 통합 테스트 추가 설명은 생략하지만, 'tests/Http/Controllers' 디렉토리 하위에 포함된 코드들을 살펴보고, 테스트를 수행해 볼 것을 권장한다. 필자가 아직은 실력이 미천해서 외부로 HTTP 요청이 발생하는 'SocialController' 와 이메일을 보내야 하는 'PasswordsController' 부분은 테스트 코드를 쓰지 못했다 (PR 또는 가르침 환영합니다 ^^/) ```bash $ phpunit ``` ### 디버그 및 자잘한 개선 'config/services.php' 에 하드코드로 박아 놓았던 Github 소셜 로그인 관련 설정 값들을 '.env'로 옮겼다. 로그인 하지 않은 상태에서 `@if (auth()->user()->isAdmin() ...)` 이 들어간 뷰를 방문하면 null 포인터 에러가 나는 버그가 있었다. `@if ($currentUser and ($currentUser->isAdmin() ...))` 으로 변경하였다. 로그인을 하면 `auth()->user()` 는 로그인한 사용자에 해당하는 `App\User` 인스턴스를 반환하는데, 로그인 되어 있지 않으면 `null`을 반환한다. `null->isAdmin()` 은 당연히 성립할 수 없는 코드이다. 아울러, 뷰 코드에 `Auth::check()` 로 된 부분들도 `App\Http\Controlelrs\Controller::__construct()` 에서 뷰에 공유한 `$currentUser` 변수로 대체하였다. 만들었으면 써야 하기에... 코드에디터(또는 IDE)에서 Cmd + Mouse Click 으로 , Facade 에 연결된 메소드로 이동을 쉽게 하기 위해서 ['barryvdh/laravel-ide-helper'](https://github.com/barryvdh/laravel-ide-helper) 패키지를 Dev Dependency 로 추가하였다. `$ composer require barryvdh/laravel-ide-helper --dev`. 설정법은 스스로 찾아서 적용하기 바라고, 설명하고 싶었던 것은 다른 것이다. 개발 과정 중에만 필요한 패키지를 설치하고, 해당 패키지가 제공하는 ServiceProvider를 'config/app.php' 에 등록할텐데, production 서버에 올릴 때 매번 불필요한 패키지 라인을 주석처리 하는 것은 귀찮은 일이다. 이때 `App\Providers\AppServiceProvider` 를 이용하면 편리하게 local 또는 dev 환경일 때만 로드되도록 할 수 있다. 참고로 아래 코드에서 `$this->app->environment()` 는 `\App::environment()` 또는 `app()->environment()` 로도 쓸 수 있다. ```php // app/Providers/AppServiceProvider.php public function register() { if ($this->app->environment('local')) { $this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class); } } ``` --- - [목록으로 돌아가기](../readme.md) - [39강 - Attachment 기능 구현](39-attachments.md) - [40강 - Comment 기능 구현](40-comments.md) ================================================ FILE: lessons/33-social-login.md ================================================ --- extends: _layouts.master section: content current_index: 34 --- # 실전 프로젝트 2 - Forum ## 33강 - 소셜 로그인 소셜 로그인을 지원하지 않는 서비스는 구닥다리로 느껴진다. 소셜 로그인은 내 서비스에 들어 온 사용자의 신분확인을 제 3자에게 위탁(아웃소싱)하는 행위라 보면 되며, 기술적인 백그라운드는 Oauth 이다 (동작 시퀀스가 궁금하다면 [조대협님의 블로그](http://bcho.tistory.com/913)를 참조하자). 간단히만 설명하자면... 내 서비스에서 사용자가 소셜로그인 버튼을 누르면, 우리 서비스('myProject'라 하자.)에 대한 여러가지 정보를 쿼리스트링으로 담고 소셜로그인 서비스 제공자의 페이지로 이동하게 된다. 표시된 UI 에서 사용자가 승인을 누르게 되는데, 이는 "내가 myProject 서비스에 이 소셜서비스의 권한을 위임해 줄게. 다는 안되고 UI 아래에 표시된 scope로 지정된 만큼만..." 의 의미이다. 즉 myProject는 소셜로그인 서비스를 통해 사용자의 신원을 확인하게 되는 과정인 것이다. ![](./images/33-social-login-img-03.png) 이 코스에서는 github의 소셜로그인을 이용해 본다. ### Socialite 확장 기능 소셜로그인을 위한 구현체는 라라벨 기본 패키지에 포함되어 있지 않고 별도로 설치해야 한다. ```bash $ composer require "laravel/socialite:2.0.*" ``` ```php // config/app.php 'providers' => [ // Other service providers... Laravel\Socialite\SocialiteServiceProvider::class, ], 'aliases' => [ // Other aliases... 'Socialite' => Laravel\Socialite\Facades\Socialite::class, ] ``` ### Oauth Credential 설정 [Github Developer Applications Console](https://github.com/settings/developers) 을 방문하여 'Register new application' 버튼을 눌러 아래 그림과 같이 정보를 입력하자. Authorization callback URL 은 'http://localhost:8000/auth/github/callback'으로 하자. ![](./images/33-social-login-img-01.png) ![](./images/33-social-login-img-02.png) Application 등록이 완료되어 위 그림과 같은 등록정보 페이지를 확인할 수 있는데, 여기서 'Client ID'와 'Client Secret'를 복사하여, config/services.php 에 github 소셜로그인 정보를 셋팅한다. 물론 .env 파일에 넣고, `env()` Helper 로 읽어 오는 것이 정석이다. ```php // config/services.php return [ // Other services setting 'github' => [ 'client_id' => '복사한 Client ID', 'client_secret' => '복사한 Client Secret', 'redirect' => route('session.github.callback'), ], ]; ``` ### Route 정의 공식문서대로 구현하자. 대신 Route 이름을 주자. ```php Route::group(['prefix' => 'auth', 'as' => 'session.'], function () { // Other route definitions... /* Social Login */ Route::get('github', [ 'as' => 'github.login', 'uses' => 'Auth\AuthController@redirectToProvider' ]); Route::get('github/callback', [ 'as' => 'github.callback', 'uses' => 'Auth\AuthController@handleProviderCallback' ]); }); ``` ### 컨트롤러 메소드 정의 app/Http/Controllers/Auth/AuthController.php 에 아래 메소드를 추가한다. ```php class AuthController extends Controller { ... public function redirectToProvider() { return \Socialite::driver('github')->redirect(); } public function handleProviderCallback() { $user = \Socialite::driver('github')->user(); dd($user); } } ``` `redirectToProvider()` 메소드는 사용자가 소셜로그인 버튼을 눌렀을 때, Github 승인 페이지로 이동시켜주는 Redirect 응답을 만들어 준다. Github 쪽에서 모든 작업이 완료되면, 앞 절에서 우리가 설정한 Authorization callback URL로 Redirect 시키면서 Payload로 여러가지 정보들을 넘겨준다. 요청을 받은 `handleProviderCallbck()` 메소드는 Github 로 부터 넘겨 받은 Payload를 기반으로 `Laravel\Socialite\Two\User` 인스턴스를 만든다. 'http://localhost:8000/auth/github' 주소를 직접 쳐서 여기까지 테스트를 해 보자. ![](./images/33-social-login-img-04.png) ### 소셜 사용자 등록 및 로그인 처리 Github를 이용해서 로그인한 사용자를 우리 서비스의 사용자로도 등록시키자. 또, 세션을 만들어야 하므로, 로그인 처리도 하기 위해 `handleProviderCallback()` 메소드를 업데이트하자. 엘로퀀드 모델에서 `firstOrCreate()` 메소드는 인자로 주어진 정보와 일치하는 레코드가 있으면 반환하고, 없으면 새로 생성하는 역할을 한다. ```php class AuthController extends Controller { ... public function handleProviderCallback() { $user = \Socialite::driver('github')->user(); $user = User::firstOrCreate([ 'name' => $user->getName(), 'email' => $user->getEmail(), ]); auth()->login($user, true); return redirect(route('home')); } ``` 'http://localhost:8000/auth/github' 주소를 직접 쳐서 여기까지 테스트를 해 보자. ![](./images/33-social-login-img-05.png) **`참고`** Github 사용자의 경우, 현재는 비밀번호가 없기 때문에 네이티브 로그인 기능으로 로그인할 수 없다. users 테이블에 소셜로그인으로 생성된 사용자에 대한 유효성을 표시하는 플래그를 만들고, 로그인 이메일은 맞는데 비밀번호가 없고 소셜 로그인 유효성 플래그가 있는 경우, 비밀번호를 추가로 받아 네이티브로도 로그인할 수 있게 하는 등의 기능 확장은 /각/자/ 해 보자. 또, Github 뿐만 아니라, 여러 소셜 로그인 공급자를 등록할 수 있도록 Social 모델을 만들어서 User 모델과 연결시키는 수준의 기능확장도 /각/자/ 도전해 보자. ### 소셜 로그인 버튼 추가 사용자가 Github 를 이용해 우리 서비스에 로그인할 수 있도록 로그인 페이지에 버튼을 달아 주자. ```html @section('style') @stop @section('content')
{!! csrf_field() !!} ... @stop ``` ![](./images/33-social-login-img-06.png) --- - [목록으로 돌아가기](../readme.md) - [32강 - 사용자 로그인](32-login.md) - [34강 - 사용자 역할](34-role.md) ================================================ FILE: lessons/34-role.md ================================================ --- extends: _layouts.master section: content current_index: 35 --- # 실전 프로젝트 2 - Forum ## 34강 - 사용자 역할 역할 기능을 이용해 RBAC (== Role Based Access Control)을 구현할 것이다. 앞에서 배운 'auth' 미들웨어가 할 수 있는 것은 특정 엔드포인트에 들어가려면 로그인(== Authentication, 인증)이 필요하다 정도라면, 접근 제어 또는 권한 부여(== Authorization 또는 Access Control)는 특정 엔드포인트는 admin 역할을 가진 사용자만 들어갈 수 있도록 할 수 있다. 우리 프로젝트를 예로 들면, 포럼 글은 로그인한 사용자 누구나 볼 수 있지만, 수정이나 삭제는 admin 또는 소유자만 할 수 있도록 하는 것이 권한 부여의 기능이다. ### 패키지 설치 사용자에게 역할을 부여하기 위해 bican/roles 패키지를 이용할 것이다. 라라벨에도 [Authorization](http://laravel.com/docs/authorization) 기능을 지원하고 있으나, 필자에겐 사용법이 너무 어려웠다. ```bash $ composer require "bican/roles: 2.1.*" ``` ```php // config/app.php return [ 'providers' => [ // Other service providers ... Bican\Roles\RolesServiceProvider::class, ], ]; ``` ```bash $ php artisan vendor:publish --provider="Bican\Roles\RolesServiceProvider" --tag=config $ php artisan vendor:publish --provider="Bican\Roles\RolesServiceProvider" --tag=migrations $ php artisan migrate ``` User 모델을 수정하자. 라라벨 네이티브 Authorization을 사용하지 않을 것이므로 `AuthorizableContract` 와 `Authorizable` trait를 삭제하자. ```php use ... use Bican\Roles\Traits\HasRoleAndPermission; use Bican\Roles\Contracts\HasRoleAndPermission as HasRoleAndPermissionContract; class User extends Model implements AuthenticatableContract, /*AuthorizableContract,*/ CanResetPasswordContract, HasRoleAndPermissionContract { use Authenticatable; use CanResetPassword; use HasRoleAndPermission; ... } ``` Route 별로 역할에 따른 권한부여를 쉽게 하기 위해서, bican/roles 패키지에 내장되어 배포되는 미들웨어를 app/Http/Kerlen.php 에 등록해 놓자. 나중에 Route에서 `'middleware' => 'role:admin'` 또는 컨트롤러에서 `$this->middleware('role:admin')` 처럼 사용할 수 있다. ```php class Kernel extends HttpKernel { ... protected $routeMiddleware = [ // Other middleware registrations ... 'role' => \Bican\Roles\Middleware\VerifyRole::class, ]; } ``` ### 역할을 만들자. 관리자모드에서 사용자와 역할을 관리하는 UI를 만드는 것이 좋다. 여기서는 artisan CLI의 tinker 코맨드를 이용한다. ```bash $ php artisan tinker >>> $admin = Bican\Roles\Models\Role::create([ ... 'name' => 'Admin', ... 'slug' => 'admin' ... ]); => Bican\Roles\Models\Role {#718 name: "Admin", slug: "admin", updated_at: "2015-11-18 06:10:59", created_at: "2015-11-18 06:10:59", id: 1, } >>> $member = Bican\Roles\Models\Role::create([ ... 'name' => 'Member', ... 'slug' => 'member' ... ]); => Bican\Roles\Models\Role {#730 name: "Member", slug: "member", updated_at: "2015-11-18 06:11:57", created_at: "2015-11-18 06:11:57", id: 2, } ``` User 13번에 admin 역할을 부여하고, 나머지는 member 역할을 부여할 것이다. ```bash $ php artisan tinker >>> $users = App\User::where('id', '!=', 13)->get(); >>> $users->map(function($user) { ... $user->roles()->sync([2]); ... }); >>> $user = App\User::find(13); >>> $user->roles()->sync([1]); ``` User 와 Role 은 Many to Many 관계를 갖는다. 1명의 User가 여러 개의 Role을 가질 수 있고, admin Role을 가진 User는 여러 명이 있을 수 있다. Forum, Tag, Comment 모델을 다룰 때 뒤에서 다시 살펴볼 것이다. **`참고`** `Bican\Roles\Models\Role` 모델과 `App\User` 모델에 추가한 trait를 잘 따라가보면, 우리가 흔히 보았던 `return $this->belongsToMany('App\User')` 와 같은 관계 설정을 확인할 수 있다. `map()` 메소드는 `array_map()`과 같은 동작을 한다. Many To Many 관계는 role_user 란 피봇테이블을 이용하여 관계를 기록하는데, `$user->roles()->attach(int|array $id)` 로 관계를 기록하고, `$user->roles()->detach(int|array $id)`로 관계를 끊을 수 있다. `$user->roles()->sync(array $ids)` 메소드는 인자로 넘겨 받은 $ids 목록과 피봇테이블에 써진 관계를 비교해, 인자에 없고 테이블에 있으면 테이블에서 해당 $id들 삭제하고, 인자에 있고 테이블에 없으면 테이블에 해당 $id들을 추가해 준다. ### 테스트 지금 당장 쓰지 않으니, 모든 기능이 동작하는 지 확인만하고 넘어가도록 하자. ```bash $ php artisan tinker >>> App\User::find(12)->is('admin'); => false >>> App\User::find(13)->is('admin'); => true >>> App\User::find(13)->is('admin|member', true); # == is(['admin', 'member'], true); or isAll(['admin', 'member']); => false ``` --- - [목록으로 돌아가기](../readme.md) - [33강 - 소셜 로그인](33-social-login.md) - [35강 - 다국어 지원](35-locale.md) ================================================ FILE: lessons/35-locale.md ================================================ --- extends: _layouts.master section: content current_index: 36 --- # 실전 프로젝트 2 - Forum ## 35강 - 다국어 지원 필자도 좀 오버했다는 생각이 들긴 한다. 그래도 기획에 들어 있는 내용이니 해 보자. ### 설계 의사 결정 생각을 해 보자. 뷰(UI)에 제시된 영어/한국어 선택지 중 사용자가 선호하는 언어를 선택한다. > Q : 사용자의 선호를 서비스에 어떻게 전달할 것인가? > A : 링크에 쿼리스트링으로 달아서 사용자 선택값을 전달하도록 하자. 사용자의 선호를 기억해 두었다가 다음번에 방문했을 때는 링크 선택지를 다시 누르지 않고 기존에 선택한 언어로 서비스가 제공되어야 한다. 기억해둔다 == 저장한다. > Q : 어디에 저장할 것인가? > A : 필자는 쿠키로 선택했다. User 모델에 써 두는 것은 사용자가 로그인 하기 전에 알 수 없기 때문이다. 그런데, 잘 생각해 보면, > Q : 브라우저 요청에 따라 서버에서 뷰를 만들고 응답하기 전에 언어값이 반영되어야 한다. > A : 즉, 브라우저의 요청에 붙은 쿠키값을 읽어 라라벨이 부트업되는 시점부터 전체 프레임웍의 언어가 바뀌어 있어야 한다는 의미다. 어렵다~ **`잡담`** 쿠키는 프론트엔드, 즉 브라우저에 저장된다. 가령, 폰트 사이즈에 대한 사용자 설정이라면 서버를 거칠 필요가 없이 자바스크립트에서 미리 저장된 쿠키 또는 localStorage 를 읽어서 동적으로 CSS 속성을 변경하면 된다. 그런데, 폰트 사이즈가 아니다. AngularJS와 같은 프론트엔드 프레임웍을 사용한다면, 서버에서 언어 딕셔너리 전체를 JSON으로 내려받고, 사용자가 브라우저에 저장한 값을 읽어 컨트롤러나 뷰모델에서 런타임에 동적으로 스트링들을 바꿀 수도 있다. 서버 로드를 줄이는 측면에서 좋지만, 프론트엔드쪽의 코드량이나 메모리 사용량이 늘어난다. 결국은 BE개발자와 FE개발자간의 폭탄 떠넘기기 문제이다. ### 공유된 뷰 데이터 전혀 관련없어 보이지만, 좀 있어 보면 왜 이걸 했는지 안다~. 컨트롤러에서 뷰를 반환할 때 뷰에서 쓸 데이터를 바인딩하는 것으로 입문코스에서 배웠다. 데이터 중에서는 뷰마다 달라지지 않을 뿐 더러, 모든 뷰에서 가지고 있으면 편리한 데이터들이 있다. 이 때 `View::share(string $key, mixed $value)` 메소드를 이용한다. 모든 컨트롤러가 상속하는 app/Http/Controllers/Controller.php 의 생성자에서 뷰에 쓰일 공통 데이터를 공유해 보자. ```php abstract class Controller extends BaseController { public function __construct() { $this->setSharedVariables(); } protected function setSharedVariables() { view()->share('currentLocale', app()->getLocale()); view()->share('currentUser', auth()->user()); view()->share('currentRouteName', \Route::currentRouteName()); view()->share('currentUrl', \Request::fullUrl()); } } ``` **`주의-주의-주의`** abstract Controller에서 생성자를 사용했으므로, 이를 상속 받는 컨트롤러들이 생성자를 사용하고 있다면, 반드시 `parent::__construct()` 를 호출해 주어야 하다는 점이다. DocumentsController, WelcomeController, AuthController, PasswordController 에 적용하자. 적용하지 않으면 `$current*` 변수가 없어서 에러가 난다. ```php class DocumentsController extends Controller { public function __construct(Document $document) { $this->document = $document; parent::__construct(); } ... } ``` ### 뷰를 수정하자. 사용자가 언어를 토글할 수 있는 방법은 뷰이다. 앞선 강좌에서 resources/views/layouts/footer.blade.php 를 언어 선택을 위한 정적 메뉴를 만들었던 것을 기억할 것이다. 동적 메뉴로 고치자. ```html
  • @foreach (['en' => 'English', 'ko' => '한국어'] as $locale => $language)
  • {{ $language }}
  • @endforeach
...
``` `@foreach`로 루프를 돌면서 링크를 뿌리고 있다. `$currentLocale`, `$currentUrl` 변수는 앞 절에서 공유된 뷰 데이터이다. 이 partial 뷰는 마스터 뷰에 포함되어 있고, 마스터 뷰를 이용하는 어떤 뷰라도 컴파일될 때, 이 변수들은 사용할 수 있다. English와 한국어 중 사용자가 선택한 언어에 'active' 클래스를 표시하기 위해 3항 연산자를 사용했고, 언어 변경 요청후 현재 Url로 다시 돌아 오기 위해 return 이란 쿼리스트링을 달았다. ### Route & 컨트롤러 정의 사용자가 뷰에서 누른 언어 변경 요청 링크에 응답하는 Route를 만들고, 컨트롤러에 연결하자. ```php Route::get('locale', [ 'as' => 'locale', 'uses' => 'WelcomeController@locale' ]); ``` WelcomeController를 수정한다. `Cookie::forever(string $key, mixed $value)` 메소드를 이용해서 브라우저의 요청에서 넘겨 받은 쿼리스트링의 locale 값으로(== 'en' or 'ko') 쿠키 인스턴스를 만들었다. 만들어진 쿠키 인스턴스를 다음 응답에 실어서 보내기 위해서 `Cookie::queue()` 로 예약을 걸어 두었다. 그 다음 Redirect 응답을 만드는데, 쿼리스트링에 return 값이 있을 때와 없을 때를 3항 연산자로 분기시켰다. ```php class WelcomeController extends Controller { public function __construct() { $this->middleware('auth', ['only' => ['home']]); parent::__construct(); } ... public function locale() { $cookie = cookie()->forever('locale__myProject', request('locale')); cookie()->queue($cookie); return ($return = request('return')) ? redirect(urldecode($return))->withCookie($cookie) : redirect(route('home'))->withCookie($cookie); } } ``` 여기서 서버를 띄우고 홈페이지로 접근해서 footer 영역에서 '한국어' 링크를 눌러보자. **`참고`** 라라벨이 내 보내는 쿠키는 해킹 툴들로 값을 조작하는 것을 방지하지 위해 암호화 되어 있다. `Crypt::decrypt(string $payload)` 메소드로 풀어서 써야 한다. ![](./images/35-locale-img-01.png) 브라우저에 저장된 쿠키 값을 읽어서 tinker 로 해독해 보았다. ```bash $ php artisan tinker >>> Crypt::decrypt('eyJ...SJ9'); => "ko" ``` 그런데, 아무리 눌러도 '한국어' 링크가 active 상태로 바뀌지 않는다. ### AppServiceProvider 우리가 이제까지 작업한 것은 쿠키 값을 셋팅하는 행위였을 뿐이다. 런타임에 기본 언어를 바꾸라고 라라벨에게 어떤 명령도 하지 않았다. app/Providers/AppServiceProvider.php 를 수정하자. `Request::cookie(string $key)` 메소드로 앞서 브라우저와 주고 받은 쿠키 값을 읽어 와서 `App::setLocale(string $locale)` 메소드로 기본 언어를 바꾸라고 라라벨에게 명령했다. ```php class AppServiceProvider extends ServiceProvider { public function boot() { if ($locale = request()->cookie('locale__myProject')) { app()->setLocale(\Crypt::decrypt($locale)); } } ... } ``` 다시 확인해 보자. 이제 잘 될 것이다. **`참고`** ServiceProvider의 `boot()`와 `register()` 메소드들은 라라벨이 부트업될 때 실행된다. 둘 간의 차이점은 [공식문서](http://laravel.com/docs/master/providers#writing-service-providers)에 잘 설명되어 있다. 쉽게 `register()`에서는 외부에서 가져온 인스턴스 생성등 라라벨 프레임웍과 크게 무관한 작업을, `boot()`에서는 프레임웍의 기능이 어느 정도 살아 나서, 사용자 코드들을 수행하기 직전에 수행된다고 생각하면 된다. 즉, `register()` 에서는 Helper나 Event 등이 동작하지 않는 상태인 것이다. ### 기본 언어 설정 기본 언어는 config/app.php 에 'locale' 키로 설정되어 있다. 앞에서 우리가 한 것은 런타임에 언어를 바꾸라고 변경한 것이다 'fallback_locale'은 곧 배울 `trans(string $key)` Helper 에서 $key 에 해당하는 언어별 스트링 정의가 없을 경우, 폴백되는 언어 설정의 의미한다. ```php return [ 'locale' => 'en', 'fallback_locale' => 'en', ]; ``` ### 언어 파일 만들기 뷰에서 `trans(string $key)` 로 언어 파일을 사용할 수 있다. 언어 딕셔너리들은 resources/lang 아래에 정의되어 있다. 현재는 'en' 디렉토리만 존재하는데, 기본언어 또는 런타임에 설정한 언어가 'en' 이라면, 'en' 디렉토리 밑에서 `$key` 값을 찾게 된다. 즉, ko 란 디렉토리를 만들고, 딕셔너리를 정의해 놓으면 한국어 서비스를 할 수 있다는 의미이다. 그리고, 뷰에 하드코드로 박아놓은 스트링들은 `{{ trans('auth.failed') }}` 식으로 모두 바꾸도록 하자. ```bash $ php artisan tinker >>> trans('auth.failed'); => "These credentials do not match our records." >>> app()->setLocale('ko'); >>> trans('auth.failed'); => "로그인 정보가 정확하지 않습니다." ``` 필자는 resources/lang/en/auth.php에 딕셔너리 키들을 더 추가하고, documents.php와 forum.php 를 추가로 생성했다. 이 부분은 코드를 참고해서 각자 알아서 하셔야 한다. 딕셔너리에서 변수를 사용하는 예 살펴보자. 딕셔너리에서 `'key' => '... :변수명 ...'` 으로 쓰고, 뷰에서 `trans('key', ['변수명' => '바인딩 할 값'])`식으로 사용한다는 것을 기억하자. ```php // resources/lang/en/auth.php return [ 'title_signup_help' => '... Please login via Github ...', ]; ``` ```html

{!! trans('auth.title_signup_help', ['url' => route('session.create')]) !!}

``` ## `icon()` Helper 다국어와 뷰를 작업하는 김에... `icon()` Helper는 뷰에서 호출했을 때, FontAwesome 아이콘을 뿌리기 위한 HTML 스트링을 반환해 주는 역할을 할 것이다. `route()` Helper랑 마찬가지로 Route 이름을 넣어줄거냐? 하드코드로 뷰나 컨트롤러에 Url을 박아 넣을 거냐?의 의사결정인 것이다. 프로젝트의 성격(규모)나 개인의 선호도에 달린 것이므로 어떻게 구현할지는 각자가 알아서 선택하도록 하자. ```php // app/helpers.php ... if (! function_exists('icon')) { function icon($class, $addition = 'icon', $inline = null) { $icon = config('icons.' . $class); $inline = $inline ? 'style="' . $inline . '"' : null; return sprintf('', $icon, $addition, $inline); } } ``` ```php // config/icons.php return [ 'login' => 'fa fa-sign-in', ..., ]; ``` ```html ...
  • {!! icon('login') !!} {{ trans('auth.title_login') }}
  • ... ``` ![](./images/35-locale-img-02.png) --- - [목록으로 돌아가기](../readme.md) - [34강 - 사용자 역할](34-role.md) - [36강 - 마이그레이션과 모델](36-models.md) ================================================ FILE: lessons/36-models.md ================================================ --- extends: _layouts.master section: content current_index: 37 --- # 실전 프로젝트 2 - Forum ## 36강 - 마이그레이션과 모델 이번 강좌에서는 포럼에서 사용할 테이블들을 생성하기 위한 마이그레이션을 만들고, 각 테이블에 대응되는 엘로퀀트 모델을 만들것이다. 이번 강좌를 통해 One to Many, Many to Many, Polymorphic Many to Many 등 모델간 관계를 배울 것이다. ### 테이블 설계 [31강 - 포럼 개발 기획](31-forum-features.md)을 참고해서 articles, comments, tags, attachments 총 4개의 메인 테이블과 article_tag 란 피봇 테이블로 설계해 보았다. ![](./images/36-models-img-01.png) - Article 모델 - Article 인스턴스는 1명의 User가 작성한 것이다. (외래키 'author_id') - 하나의 Article에 대해 여러 개의 Comment가 달릴 수 있다. - 하나의 Comment를 Best Answer로 선택할 수 있다. (외래키 'solution_id') - 하나의 Article에 여러 개의 Tag를 붙일 수 있다. - 하나의 Article에 여러 개의 첨부파일을 붙일 수 있다. - Article 인스턴스에 'notification' 필드값이 셋팅되어 있는 상태에서, Comment가 등록되면 'author_id'를 가진 User에게 이메일을 보낼 것이다. - Comment 모델 - Comment 인스턴스는 특정 User가 작성한 것이다. (외래키 'commentable_id') - Comment의 Comment, 즉 대댓글 기능을 위해, 하나의 Comment는 부모 Comment를 가질 수 있다. 즉, Comment 내부에서 재귀적인 관계가 형성된다. - Comment는 Article 모델과 관계를 가질 수도 있지만, 다른 모델과 다형적인 관계를 가질 수도 있다. (Polymorphic Many to Many라 한다. 'commentable_type', 'commentable_id'로 필드명을 정했다.) - Tag 모델 - 하나의 Tag 가 여러 개의 Article과 연결되어 있다. - Attachment 모델 - Attachment 인스턴스는 하나의 Article에 소속된다. 가령, 우리 프로젝트에 Post, Application과 같은 모델이 더 있고, 모든 모델들은 모두 댓글 기능이 있다고 가정해 보자. 이 때, ArticleComment, PostComment, ApplicationComment로 총 3개의 모델을 만들고 각각 관계를 연결하는 것이 아니라, [**Polymorphic Many to Many**](http://laravel.com/docs/eloquent-relationships#many-to-many-polymorphic-relations) 관계를 이용할 수 있다. Comment 모델 하나로, Article의 Comment로도, Post의 Comment로도 쓸 수 있는 것이다. Article과 Tag는 Many to Many 관계를 가지고, 이를 위해 article_tag란 피봇테이블을 도입했다. Role과 User간의 Many to Many 관계 연결을 위한 role_user 피봇테이블은 [34강 - 사용자 역할](34-role.md)을 진행하는 과정에서 'bican/role' 패키지를 설치하는 과정에 생성된 것이다. ### 마이그레이션 작성 artisan CLI를 이용한다. `--migration` 옵션을 주면, 모델을 만들 때 마이그레이션도 같이 생성해 준다. article_tag 테이블을 만들 때는 `--create=테이블이름` 옵션을 이용했는데, 이 옵션을 주면 마이그레이션 작성시 타이핑을 좀 줄일 수 있다 (차이는 직접 경험해 보자). ```bash $ php artisan make:model Comment --migration $ php artisan make:model Article --migration $ php artisan make:model Tag --migration $ php artisan make:model Attachment --migration $ php artisan make:migration create_article_tag_table --create="article_tag" ``` 마이그레이션을 만들 때 주의할 점이 있다. 마이그레이션 파일들은 파일명에 타임스탬프를 담고 있고, 생성 순서에 따라 순차적으로 실행된다. 우리 프로젝트에서는 create_articles_table 마이그레이션에서 comments 테이블과 'solution_id'를 이용해서 외래키 관계를 설정하고 있다. comments 테이블이 먼저 생성되어 있어야 articles 테이블에서 comment_id 외래키로 테이블간 연결할 수 있다. 즉, 마이그레이션은 순서가 중요하다. 혹, 순서가 잘못되어 마이그레이션 실행시 오류가 발생했다면... 이번 마이그레이션 실행으로 인해, 생성된 테이블이나, migrations 테이블에 추가된 엔트리가 있다면 삭제한다. 수동으로 파일명의 타임스탬프를 조작해 주어 실행 순서를 변경시킨 후, 마이그레이션을 다시 실행할 수 있다. `down()` 메소드에 해당하는 리버스 마이그레이션은 아래에서 생략하였다. ```php // database/migrations/create_comments_table class CreateCommentsTable extends Migration { public function up() { Schema::create('comments', function (Blueprint $table) { $table->increments('id'); $table->integer('author_id')->unsigned()->index(); $table->string('commentable_type'); $table->integer('commentable_id')->unsigned(); $table->integer('parent_id')->unsigned(); $table->string('title'); $table->text('content'); $table->timestamps(); $table->foreign('author_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('parent_id')->references('id')->on('comments')->onDelete('cascade'); }); } } ``` ```php // database/migrations/create_articles_table class CreateArticlesTable extends Migration { public function up() { Schema::create('articles', function (Blueprint $table) { $table->increments('id'); $table->integer('author_id')->unsigned()->index(); $table->string('title'); $table->text('content'); $table->integer('solution_id')->unsigned()->nullable(); $table->boolean('notification')->default(1); $table->timestamps(); $table->foreign('author_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('solution_id')->references('id')->on('comments'); }); } } ``` ```php // database/migrations/create_tags_table class CreateTagsTable extends Migration { public function up() { Schema::create('tags', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->string('slug')->index(); $table->timestamps(); }); } } ``` ```php // database/migrations/create_attachments_table class CreateAttachmentsTable extends Migration { public function up() { Schema::create('attachments', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->integer('article_id')->unsigned()->index(); $table->timestamps(); }); } } ``` ```php // database/migrations/create_article_tag_table class CreateArticleTagTable extends Migration { public function up() { Schema::create('article_tag', function (Blueprint $table) { $table->increments('id'); $table->integer('article_id')->unsigned(); $table->integer('tag_id')->unsigned(); $table->timestamps(); $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade'); $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); }); } } ``` 마이그레이션을 실행하자. ```bash $ php artisan migrate ``` ### 모델 작성 테이블 설계에서 정리한 내용을 기반으로 모델간의 관계를 설정하고, `$fillable`, `$hidden` 필드를 설정하자. ```php // app/User.php class User extends Model implements ... { ... /* Relationships */ public function articles() { return $this->hasMany(Article::class, 'author_id'); } public function comments() { return $this->hasMany(Comment::class, 'author_id'); } } ``` ```php // app/Comment.php class Comment extends Model { protected $fillable = [ 'commentable_type', 'commentable_id', 'author_id', 'parent_id', 'title', 'content' ]; protected $hidden = [ 'author_id', 'commentable_type', 'commentable_id', 'parent_id' ]; /* Relationships */ public function author() { return $this->belongsTo(User::class, 'author_id'); } public function commentable() { return $this->morphTo(); } public function replies() { return $this->hasMany(Comment::class, 'parent_id'); } public function parent() { return $this->belongsTo(Comment::class, 'id', 'parent_id'); } } ``` ```php // app/Article.php class Article extends Model { protected $fillable = [ 'author_id', 'title', 'content', 'notification' ]; protected $hidden = [ 'author_id', 'solution_id', 'notification' ]; /* Relationships */ public function author() { return $this->belongsTo(User::class, 'author_id'); } public function tags() { return $this->belongsToMany(Tag::class); } public function comments() { return $this->morphMany(Comment::class, 'commentable'); } public function solution() { return $this->hasOne(Comment::class, 'id', 'solution_id'); } public function attachments() { return $this->hasMany(Attachment::class); } } ``` ```php // app/Tag.php class Tag extends Model { protected $fillable = [ 'name', 'slug' ]; /* Relationships */ public function articles() { return $this->belongsToMany(Article::class); } } ``` ```php // app/Attachment.php class Attachment extends Model { protected $fillable = [ 'name' ]; protected $hidden = [ 'article_id' ]; /* Relationships */ public function article() { return $this->belongsTo(Article::class); } } ``` ### 모델 팩토리 작성 Tag 팩토리의 `$faker->optional(0.9, 'Laravel')->word` 문법은 [fzaninotto/faker API](https://github.com/fzaninotto/Faker#unique-and-optional-modifiers), Attachment 팩토리의 `$faker->randomElement(['png', 'jpg'])` 문법은 [fzaninotto/faker API](https://github.com/fzaninotto/Faker#formatters)를 참고하자. ```php $factory->define(App\User::class, function (Faker\Generator $faker) { return [ 'name' => $faker->name, 'email' => $faker->email, 'password' => bcrypt(str_random(10)), 'remember_token' => str_random(10), ]; }); $factory->define(App\Article::class, function (Faker\Generator $faker) { return [ 'title' => $faker->sentence(), 'content' => $faker->paragraph(), ]; }); $factory->define(App\Comment::class, function (Faker\Generator $faker) { return [ 'title' => $faker->sentence, 'content' => $faker->paragraph, ]; }); $factory->define(App\Tag::class, function (Faker\Generator $faker) { $name = ucfirst($faker->optional(0.9, 'Laravel')->word); return [ 'name' => $name, 'slug' => str_slug($name), ]; }); $factory->define(App\Attachment::class, function (Faker\Generator $faker) { return [ 'name' => sprintf("%s.%s", str_random(), $faker->randomElement(['png', 'jpg'])), ]; }); ``` `App\Attachment` 모델은 사용자가 업로드한 파일의 이름(또는 경로)을 담고 있는 모델이란 것을 이해하고 넘어가자. 가령, 사용자가 'foo.png'를 업로드 했다면, 이 모델의 'name' Attribute는 'foo.png'가 되는 것이다. **`참고`** 필자가 필드(field), Attribute, Property 란 용어를 혼용해서 사용하는데 모두 같은 의미로 이해하자. HTML 폼이나 DB 테이블에서는 필드라는 이름이 좀 더 적절하고, 오브젝트 컨텍스트에서는 Attribute나 Property란 용어가 더 적절할 것이다. ### Seeder 작성 편의상 app/database/seeds/DatabaseSeeder.php 에 전부 몰아 넣었지만, 쪼개서 설명한다. ```php use Faker\Factory as Faker; class DatabaseSeeder extends Seeder { public function run() { /* * Prepare seeding */ $faker = Faker::create(); DB::statement('SET FOREIGN_KEY_CHECKS=0'); Model::unguard(); /* * Seeding users table */ App\User::truncate(); factory(App\User::class)->create([ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => bcrypt('password') ]); factory(App\User::class, 9)->create(); $this->command->info('users table seeded'); ``` `truncate()` 메소드로 users 테이블을 비운다. 테스트를 위해 사용할 john@example.com 계정을 만들고, 나머지 Dummy 계정 9개를 만들었다. ```php /** * Seeding roles table */ Bican\Roles\Models\Role::truncate(); DB::table('role_user')->truncate(); $adminRole = Bican\Roles\Models\Role::create([ 'name' => 'Admin', 'slug' => 'admin' ]); $memberRole = Bican\Roles\Models\Role::create([ 'name' => 'Member', 'slug' => 'member' ]); App\User::where('email', '!=', 'john@example.com')->get()->map(function($user) use($memberRole) { $user->attachRole($memberRole); }); App\User::whereEmail('john@example.com')->get()->map(function($user) use($adminRole){ $user->attachRole($adminRole); }); $this->command->info('roles table seeded'); ``` 'Admin' 과 'Member' 역할을 만들고, john@example.com 계정에는 'Admin' 역할을, 나머지 계정에는 'Member' 권한을 할당하였다. `$user->attachRole(Bican\Roles\Models\Role|int $role)` 메소드는 'bican/role' 에서 제공하는 Helper 메소드로, `$user->roles()->attach(array $roleId)` 또는 `$user->roles()->sync(array $roleId)`와 같은 역할을 한다. ```php /* * Seeding articles table */ App\Article::truncate(); $users = App\User::all(); $users->each(function($user) use($faker) { $user->articles()->save( factory(App\Article::class)->make() ); }); $this->command->info('articles table seeded'); ``` 앞서 생성된 모든 `App\User` 컬렉션을 `$users` 변수에 담았다. `each()` 메소드로 루프를 돌면서, `article()` 관계를 이용해 `App\Article` 인스턴스를 만들었다 ```php /** * Seeding comments table */ App\Comment::truncate(); $articles = App\Article::all(); $articles->each(function($article) use($faker, $users) { $article->comments()->save( factory(App\Comment::class)->make([ 'author_id' => $faker->randomElement($users->lists('id')->toArray()) ]) ); }); $this->command->info('comments table seeded'); ``` Article 모델과 동일하게, 이번에는 앞서 생성된 `App\Article` 전체 컬렉션을 `$articles` 변수에 담고, 루프를 돌면서 미리 모델에서 정의한 관계를 이용해 `App\Comment` 인스턴스를 만든다. 여기서 `factory()->make()` 메소드에 인자로 넘긴 값은, 모델 팩토리에서 정의한 값을 오버라이드하거나 추가하는 값이다. 'author_id' 필드를 `$faker->randomElement()` 메소드를 이용하여 넣어 주었다. ![](./images/36-models-img-02.png) ```php /* * Seeding tags table */ App\Tag::truncate(); DB::table('article_tag')->truncate(); $articles->each(function($article) use($faker) { $article->tags()->save( factory(App\Tag::class)->make() ); }); $this->command->info('tags table seeded'); ``` 'article_tag' 피봇테이블은 모델을 정의하지 않았기 때문에, DB Facade를 이용하여 `truncate()` 하였다. ```php /* * Seeding attachments table */ App\Attachment::truncate(); if (! File::isDirectory(attachment_path())) { File::deleteDirectory(attachment_path(), true); } $articles->each(function($article) use($faker) { $article->attachments()->save( factory(App\Attachment::class)->make() ); }); $files = App\Attachment::lists('name'); if (! File::isDirectory(attachment_path())) { File::makeDirectory(attachment_path(), 777, true); } foreach($files as $file) { File::put(attachment_path($file), ''); } $this->command->info('attachments table seeded'); /** * Close seeding */ Model::reguard(); DB::statement('SET FOREIGN_KEY_CHECKS=1'); } } ``` `App\Attachment` Seeder에서는 DB 테이블에 저장되는 모델 뿐 아니라, 사용자가 업로드하여 서버의 파일 시스템에 저장되는 파일들도 같이 생성해 주어야 한다. 해서, `truncate()` 하는 과정에서 파일이 저장된 디렉토리를 청소해 주었고, Seeding 하면서 Dummy 파일도 같이 생성해 주었다. ### Helper Function Seeding 과정에서 사용한 `attachment_path()` Helper 을 app/helpers.php 에 만들어 주자. ```php if (! function_exists('attachment_path')) { /** * @param string $path * * @return string */ function attachment_path($path = '') { return public_path($path ? 'attachments'.DIRECTORY_SEPARATOR.$path : 'attachments'); } } ``` ### 테스트 Seeding 을 실행하자. 마이그레이션부터 완전 다시 할 것이다. ```bash $ php artisan migrate:refresh --seed # Above command is equivalent to the following 3 commands... # php artisan migrate:reset # php artisan migrate # php artisan db:seed ``` 오류 없이 실행되었다면, 마이그레이션, 모델과 관계설정, 모델 팩토리, Seeder 모두가 정상적으로 작성된 것이다. 더블 체크를 위해 artisan CLI 로 더 확인해 보아도 좋다. ```bash $ php artisan tinker >>> $user = App\User::find(1); >>> $user->articles()->first(); >>> $user->comments()->first(); >>> $article = App\Article::find(1); >>> $article->author()->first(); >>> $article->tags()->first(); >>> $article->comments()->first(); >>> $article->attachments()->first(); >>> $comment = App\Comment::find(1); >>> $comment->author()->first(); >>> $comment->commentable()->first(); >>> $comment->replies()->first(); >>> $comment->parent()->first(); >>> $tag = App\Tag::find(1); >>> $tag->articles()->first(); >>> $attachment = App\Attachment::find(1); >>> $attachment->article()->first(); ``` --- - [목록으로 돌아가기](../readme.md) - [35강 - 다국어 지원](35-locale.md) - [37강 - Article 기능 구현](37-articles.md) ================================================ FILE: lessons/37-articles.md ================================================ --- extends: _layouts.master section: content current_index: 38 --- # 실전 프로젝트 2 - Forum ## 37강 - Article 기능 구현 이번 강좌를 통해 포럼의 핵심인 Article 기능을 구현할 것이다. 캐싱이나 이벤트 등은 나중에 다듬기로 하고, 컨트롤러와 뷰 위주로 작업을 해 보자. ### Route 정의 및 컨트롤러 생성 RESTful 리소스 컨트롤러를 이용한다. ```php // app/Http/routes.php Route::resource('articles', 'ArticlesController'); ``` ```bash $ php artisan make:controller ArticlesController --resource $ php artisan route:list ``` `index()`와 `show()` 메소드, 즉 포럼 목록 보기와 개별 포럼 상세 보기만 guest 에게 허용할 것이다. 그리고, 사이드바에 모든 태그 목록을 뿌리기 위해, [35강 - 다국어 지원](35-locale.md)에서 했던 것 처럼, `$allTags` 란 변수를 포럼을 위한 모든 뷰에 공유할 것이다. ```php class ArticlesController extends Controller { public function __construct() { $this->middleware('auth', ['except' => ['index', 'show']]); view()->share('allTags', \App\Tag::with('articles')->get()); parent::__construct(); } } ``` ### 목록 보기 구현 완성된 모양을 먼저 보자. ![](./images/37-articles-img-01.png) resources/views/articles/index.blade.php 는 포럼 목록을 보여주는 뷰이다. 크게 보면 Tag 를 보여주는 왼쪽 영역과, 목록을 보여주는 오른쪽 영역으로 구분된다. 왼쪽 영역에서는 검색 폼 (layouts/partial/search.blade.php) 과 전체 태그 목록(tags/partial/index.blade.php)을 블레이드 문법으로 `@include` 하고 있다. 오른쪽은 포럼 엔트리들을 표시하는데, 각 엔트리(articles/partial/article.blade.php)는 사용자의 [Gravatar](http://ko.gravatar.com/) (users/partial/avatar.blade.php)를 보여주는 영역과 각 Article과 연결된 태그의 목록(tags/partial/list.blade.php)을 보여주는 영역으로 구성되어 있다. `ArticleController::index()` 메소드를 작성하자. Eager Loding 으로 'comments', 'author', 'tags' 관계를 포함하고, `paginate()` 메소드로 한번에 10개의 Article 인스턴스를 불러오도록 하였다. ```php use App\Article; class ArticlesController extends Controller { public function index() { $articles = Article::with('comments', 'author', 'tags')->latest()->paginate(10); return view('articles.index', compact('articles')); } } ``` 위에 나열한 뷰들을 작성하자. 레이아웃 관련된 내용들은 각자 입맛에 맞게 작성하도록 하자. ```html @extends('layouts.master') @section('content')
    @forelse($articles as $article) @include('articles.partial.article', ['article' => $article]) @empty

    {{ trans('errors.not_found_description') }}

    @endforelse
    {!! $articles->appends(Request::except('page'))->render() !!}
    @stop ``` 하위 뷰에 데이터를 넘겨 줄 때는, `@include('articles.partial.article', ['article' => $article])` 식으로 넘긴다는 것을 익혀 두자. `{{ $articles->appends(Request::except('page'))->render() }}` 는 나중에 Url 뒤에 여러 개의 쿼리스트링이 붙을 것을 대비해, 미리 `appends()` 메소드를 이용해서 page 쿼리를 빼고 나머지들은 모두 붙이도록 했다. 가령, GET /articles?solved=0 요청의 페이징 링크는 /articles?solved=0&page=1, /articles?solved=0&page=2 와 같은 식으로 생성된다. ```html
    ``` 풀텍스트 검색 기능은 나중에 구현할 것이므로, 폼만 만들어 놓고 넘어가자. ```html

    {!! icon('tags') !!} Tags

    ``` `ArticlesController::__construct()` 에서 모든 뷰에 공유한 `$allTags` 변수를 여기서 이용한다. 'active' 클래스를 표시하기 위해 삼항 연산자를 이용하였고, `Route::current()->parameter('id')` 로 현재 요청의 Route 파라미터를 얻어 왔다. 아직 Route 가 정의되지 않았으므로 이 기능은 동작하지 않는데, 곧 고칠 것이다. `$tagCount = $tag->articles->count()` 로 각 Tag 에 해당하는 Article 의 갯수를 구하고, 태그 이름 옆에 숫자로 표시하였다. `ArticlesController::__construct()` 에서 `$allTags` 변수에서 Eager Loading 을 한 이유가, 여기서 N + 1 쿼리 문제를 피하기 위해서였다. **`참고`** 태그 리스트를 포함한 왼쪽 영역은 여러 뷰에서 사용하므로, [뷰 컴포저](http://laravel.com/docs/views#view-composers)로 구성하면 더 깔끔하다. 이 코스 수준을 넘어서는 내용으로 각자 공부해서 적용해 보자. ```html
    @include('users.partial.avatar', ['user' => $article->author])
    ``` `@include('users.partial.avatar', ['user' => $article->author])` 로, `$user` 변수를 사용자 사진을 표시할 하위 뷰에 데이터를 넘겨 준다. `$commentCount = $article->comments->count()` 로, 각 Article에 연결된 Tag 의 갯수를 구하고, Article 제목 옆에 갯수를 표시하였다. 기본기 강좌에서 'created_at', 'updated_at'은 `Carbon\Carbon` 인스턴스라고 얘기한바 있다. `$article->created_at->diffForHumans()` 메소드를 쓰면 '6 hours ago' 처럼 Article 생성 시각을 표시할 수 있다. `@include('tags.partial.list', ['tags' => $article->tags])` 로, 각 Article 인스턴스에 연결된 Tag 의 목록을 넘겨 준 것을 기억해 놓자. ```html @if ($user) @else @endif ``` `gravatar_profile_url()`, `gravatar_url()` Helper를 이용하여, 사용자의 프로파일 주소와 아바타를 가져왔다. 이 Helper들은 다음 섹션에서 구현할 것이다. 로그인한 사용자만 포럼을 남길 수 있게 조치했으므로, `$user` 변수가 없을 가능성은 없다. `$user = null` 인 경우를 대비해, 미리 조심해서 나쁠 것은 없다. ```html @if ($tags->count()) {!! icon('tags') !!} @endif ``` 'articles/partial/article.blade.php' 에서 넘겨 받은 `$tags` 변수를 이용하여, 각 Article 에 연결된 Tag 들의 이름을 뿌려준다. 링크 href 속성에 '#'로 표시된 부분은 나중에 다시 업데이트할 것이다. #### Gravatar Helper ```php // app/helpers.php function gravatar_profile_url($email) { return sprintf("//www.gravatar.com/%s", md5($email)); } function gravatar_url($email, $size = 72) { return sprintf("//www.gravatar.com/avatar/%s?s=%s", md5($email), $size); } ``` ```bash $ php artisan tinker >>> gravatar_url('juwonkim@me.com'); => "//www.gravatar.com/avatar/6a7346bbad52884566008892003fc6ac?s=72" ``` 's' 쿼리는 가져올 아바타 이미지의 사이즈를 의미한다. ### 상세 보기 구현 목록에서 Article 엔트리의 제목을 누르면 'GET /articles/{id}' 로 넘어가도록 목록 보기 뷰에서 링크를 걸었다. 이 Route 에 해당하는 `ArticlesController::show()` 메소드를 작성하자. ```php class ArticlesController extends Controller { public function show($id) { $article = Article::with('comments', 'author', 'tags')->findOrFail($id); return view('articles.show', compact('article')); } } ``` 앞에서 작업을 많이 했기에 'articles/show.blade.php' 는 의외로 간단하다. Article 인스턴스의 'content' 속성값을 뿌리기 위해, 이전에 만든 `markdown()` Helper를 이용하였다. ```html @extends('layouts.master') @section('content')
    @include('articles.partial.article', ['article' => $article])

    {!! markdown($article->content) !!}

    ...
    Comment here
    @stop ``` 'Comment here' 라고 된 부분이 보일 것이다. 여기에 댓글 작성 폼과, 그간 작성된 댓글 목록이 표시될 것이다. ![](./images/37-articles-img-02.png) ### 포럼 쓰기 구현 #### 쓰기 UI 구현 새 포럼 글을 쓰는 UI는 목록 보기 뷰나 상단의 네비게이션 메뉴에 위치하면 적절할 것 같다. ```html ... ``` #### 쓰기 폼 구현 포럼을 쓰기 위한 폼을 만들자. ```php // app/Http/Controllers/ArticlesController public function create() { $article = new Article; return view('articles.create', compact('article')); } ``` `$article = new Article;` 부분은 create 와 edit 뷰에서 폼을 공용으로 사용하기 위한 편법이다. 뒤에 나오는 공용 폼에서 `` 에서 edit 뷰에서는 Article 인스턴스가 바인딩되지만, create 뷰에는 바인딩되지 않기 때문에 'Undefined variable: article' 에러가 난다. `old()` Helper 에서 세션에 `$title` 값이 있으면 쓰고, 없으면 `$article->title`로 폴백하라는 의미이다. ```html @extends('layouts.master') @section('content')
    {!! csrf_field() !!} @include('articles.partial.form')

    {!! icon('reset') !!} Reset

    @stop ``` `@include('articles.partial.form')` 은 포럼 수정하기에서도 동일하게 사용되는 폼이므로 하위 뷰로 뺐다. ```html
    {!! $errors->first('title', ':message') !!}
    {!! $errors->first('content', ':message') !!}
    @include('articles.partial.tagselector')
    ``` `@include('articles.partial.tagselector')`, 나중에 포럼을 작성/수정할 때 태그를 선택하는 UI를 추가할 것이다. 여유가 된다면, 'content' 필드에 쓴 내용을 마크다운으로 미리보기하는 기능도 추가해 볼 것이다. ![](./images/37-articles-img-03.png) #### 저장 로직 구현 기본기 강좌의 [23강 - 입력 값 유효성 검사](23-validation.md)에서는 다루지 않았는데, 여기서는 [Form Request](http://laravel.com/docs/validation#form-request-validation) 를 사용하여 사용자의 입력값 유효성 검사를 할 것이다. ```bash $ php artisan make:request ArticlesRequest ``` ```php // app/Http/Requests/ArticlesRequest.php class ArticlesRequest extends Request { public function authorize() { return true; } public function rules() { return [ 'title' => 'required', 'content' => 'required', ]; } } ``` `authorize()` 메소드에서 `false` 를 반환하면, 403 Forbidden 응답이 떨어진다. 우선은 `true`를 반환하자. `rules()` 메소드에서 각 필드별로 검사할 규칙을 정의한다. 여기서는 'required' 만 이용했다. ```php // app/Http/Controllers/ArticlesController.php use App\Http\Requests\ArticlesRequest; class ArticlesController extends Controller { public function store(ArticlesRequest $request) { $article = Article::create($request->all()); flash()->success(trans('forum.created')); return redirect(route('articles.index')); } } ``` `store()` 메소드에 원래 있던 `Illuminate\Http\Request` 대신, 방금 만든 `App\Http\Requests\ArticlesRequest` 을 주입했다. 우리가 만든 Form Request는 원래있던 Request 객체를 상속하고 있고, 거기다 유효성 검사 기능을 추가한 것이다. Form Request 는 미들웨어와 유사한 기능을 하고 있는데, 우리가 만든 Form Request를 통과하지 못하면, `store()` 메소드는 전혀 실행되지 않는다. `flash()` 메소드는 [32강 - 사용자 로그인](32-login.md) 에서 설치한 'laracasts/flash' 패키지에 포함된 Helper로, `success()`, `error()` 등에 메소드를 체인해서 사용할 수 있다. Tag UI 가 준비되면, 유효성 검사 로직과 저장 로직을 다시 손 볼 것이다. ### 포럼 수정 구현 #### 수정 UI 구현 수정 및 삭제 UI는 주로 목록 보기 또는 상세 보기에서 접근할 수 있다. 여기서는 상세 보기 뷰에서 구현한다. ```html ``` #### 수정 폼 구현 쓰기 폼에서 공용 폼을 만들었기에 한결 간편해 졌다. ```php // app/Http/Controllers/ArticlesController.php public function edit($id) { $article = Article::findOrFail($id); return view('articles.edit', compact('article')); } ``` ```html @extends('layouts.master') @section('content')
    {!! csrf_field() !!} {!! method_field('PUT') !!} @include('articles.partial.form')

    {!! icon('reset') !!} Reset

    @stop ``` `{!! method_field('PUT') !!}` 는 [13강 - RESTful 리소스 컨트롤러](13-restful-resource-controller.md) 에서 설명한 메소드 오버라이드를 위한 숨김 필드를 생성해 주는 Helper 로, `` 태그를 생성한다. #### 수정 로직 구현 포럼 쓰기에서 만든 FormRequest를 그대로 사용하자. ```php // app/Http/Controllers/ArticlesController.php public function update(ArticlesRequest $request, $id) { $article = Article::findOrFail($id); $article->update($request->except('_token', '_method')); flash()->success(trans('forum.updated')); return redirect(route('articles.index')); } ``` ![](./images/37-articles-img-04.png) ### 포럼 삭제 구현 #### 삭제 UI 구현 일반적으로 삭제는 Ajax 요청을 이용하는데, 여기서는 전통적인 폼 요청을 통해서 하도록 하자. ```html ...
    {!! csrf_field() !!} {!! method_field('DELETE') !!} {!! icon('pencil') !!} Edit
    ``` #### 삭제 로직 구현 ```php // app/Http/Controllers/ArticlesController.php public function destroy($id) { Article::findOrFail($id)->delete(); flash()->success(trans('forum.deleted')); return redirect(route('articles.index')); } ``` ### 접근 제어 (Authorization) 상세 보기에서 관리자이거나 사용자 자신이 작성한 포럼만 수정하거나 삭제할 수 있도록 할 것이다. [34강 - 사용자 역할](34-role.md) 에서 설치한 'bican/roles' 패키지가 밥값을 할 차례이다... 라고 생각하고, 이용하려고 했으나 뭔가 여의치 않다. 해서 User 와 Article 모델에 Helper 메소드를 만들었다. 어쨌든, 뷰에서 접근을 제한하여 버튼 UI 자체가 표시되지 않게 하는 게 우선이고... ```php // app/User.php public function isAdmin() { return $this->roles()->whereSlug('admin')->exists(); } ``` ```php // app/Article.php public function isAuthor() { return $this->author->id == auth()->user()->id; } ``` ```html @if (auth()->user()->isAdmin() or $article->isAuthor())
    Edit @endif ``` 다음은 CURL 코맨드 등을 이용하여 직접 Route 에 접근하는 것을 막아야 한다. 이를 위해 Route 미들웨어를 만들고, app/Http/Kernel.php 에 Route 미들웨어라고 등록할 것이다. ```bash $ php artisan make:middleware CanAccessArticle ``` ```php // app/Http/Middleware/CanAccessArticle.php class CanAccessArticle { public function handle($request, Closure $next) { $user = $request->user(); $articleId = $request->route('articles'); if (! \App\Article::whereId($articleId)->whereAuthorId($user->id)->exists() and ! $user->isAdmin()) { flash()->error(trans('errors.forbidden') . ' : ' . trans('errors.forbidden_description')); return back(); } return $next($request); } } ``` `Illuminate\Http\Request` 인스턴스에서는 `user()` 메소드를 사용할 수 있으며, `auth()->user()` 와 동일한 결과를 얻을 수있다. 로그인한 사용자 id와 Route 파라미터로 넘겨 받은 Article id로 Article 모델을 검색하여 레코드가 있으면, 작성자이므로 통과시켜 주는 식이다. **`참고`** 미들웨어에서 Route를 통해 사용자로부터 넘겨 받은 파라미터를 이용할 수 있다. [미들웨어 파라미터](http://laravel.com/docs/middleware#middleware-parameters)를 잘 이용하면, Article 모델 뿐 아니라 여러 모델에 적용할 수 있는 미들웨어를 만들수 있을 것이다. ```php // app/Http/Kernel.php protected $routeMiddleware = [ // Other Route Middlewares ... 'accessible' => \App\Http\Middleware\CanAccessArticle::class, ]; ``` 컨트롤러에서 미들웨어를 등록하자. 미들웨어는 순차적으로 실행되므로 반드시 'auth' 미들웨어 다음에 정의되어야 한다. 이유는 사용자 로그인을 먼저 걸러주고 난 이후에, 이 사용자가 해당 Route와 컨트롤러 메소드에 접근할 수 있는 지 체크하는 식이다. ```php // app/Http/Controllers/ArticlesController.php public function __construct() { ... $this->middleware('accessible', ['except' => ['index', 'show', 'create']]); } ``` #### 테스트 artisan CLI의 tinker 코맨드로 사용자의 비밀번호를 수정하자. ```bash $ php artisan tinker >>> $user = App\User::find(2); >>> $user->password = bcrypt('password'); >>> $user->save(); ``` 1번과 2번 사용자로 번갈아 로그인한 후 구현한 내용이 잘 동작하는 지 확인해 보자. --- - [목록으로 돌아가기](../readme.md) - [36강 - 마이그레이션과 모델](36-models.md) - [38강 - Tag 기능 구현](38-tags.md) ================================================ FILE: lessons/38-tags.md ================================================ --- extends: _layouts.master section: content current_index: 39 --- # 실전 프로젝트 2 - Forum ## 38강 - Tag 기능 구현 이번 강좌에서는 37강에서 구현하던 포럼의 Article 기능에 Tag 기능을 더할 것이다. ### 태그 선택 UI 구현 `ArticlesController::__construct()` 에서 뷰에 공유한 `$allTags` 변수를 이용하여, ` @foreach($allTags as $tag) @endforeach {!! $errors->first('tags', ':message') !!}
    ``` 앞서 보았다시피, 이 뷰는 'create' 와 'edit'에서 공통으로 사용하는 'resources/views/articles/partial/form.blade.php' 의 하위 뷰이다. 코드가 좀 지저분 한데, 삼항 연산자로 각 `