메인 콘텐츠로 건너뛰기
조회한 모델을 ID로 참조할 수 있는 형태로 맞추고 싶을 때나, 로컬 스코프에 결과 가공을 모으고 싶을 때는 afterQuery()를 사용할 수 있습니다. SQL 조건을 추가하는 것뿐만 아니라, 조회 결과에 대해 실행할 Closure도 쿼리 빌더에 등록할 수 있습니다. 다만 “SQL이 1회 실행될 때마다 같은 타입의 결과가 전달되는 훅”은 아닙니다. 조회 메서드에 따라 전달되는 Collection의 내용이나 호출 횟수가 달라집니다.
이 페이지는 Laravel 13을 대상으로 합니다. 공식 문서의 After Query Callbacks를 출발점으로, Eloquent 빌더와 공식 테스트에서 사용 시의 경계를 확인합니다.

로컬 스코프에 결과 가공을 모으기

모델을 ID로 참조하는 처리를 반복하는 경우에는 결과의 키를 맞추는 스코프를 준비할 수 있습니다. 다음 예제는 app/Models/Post.php에 정의합니다.
호출하는 쪽은 일반 스코프와 같은 방식으로 체이닝할 수 있습니다.
$posts는 여전히 Eloquent Collection이며, 요소도 Post 그대로입니다. get(42)는 키가 42인 모델을 반환하고, 존재하지 않으면 null을 반환합니다. select()로 조회 컬럼을 한정하는 경우에는 키로 사용하는 id도 포함하세요. 한 곳에서만 가공한다면 get()->keyBy('id')가 더 단순합니다. afterQuery()는 스코프나 재사용하는 쿼리에 조회 후의 처리도 포함하고 싶을 때 선택합니다. 스코프 자체의 정의 방법은 Eloquent 스코프를 참고하세요.

get의 실행 순서 읽기

Eloquent\Builder::get()은 글로벌 스코프 적용, 모델 생성, 지정된 관계의 Eager 로딩을 수행하고, 마지막으로 afterQuery의 콜백을 실행합니다. 일반적인 get()에서는 콜백에서 Eager 로딩된 관계를 사용할 수 있습니다. 지정하지 않은 관계에 접근하면 추가 SQL이 발생할 수 있으므로, Eager 로딩과 함께 설계합니다. retrieved 모델 이벤트와는 타이밍이 다릅니다. retrieved는 Model::newFromBuilder()에서 각 모델을 생성할 때 발생하며, get()의 Eager 로딩보다 앞섭니다. 조회한 모델의 묶음을 다루는 afterQuery()와 각 모델의 이벤트는 같은 역할이 아닙니다.
이것은 Eloquent\Builder::get()의 흐름입니다. cursor()는 관계를 Eager 로딩하지 않으며, 콜백도 모델 1건씩에 대해 실행합니다. get()과 같은 배치 처리를 기대하지 마세요.

반환값은 다음 콜백으로 전달된다

afterQuery()는 등록 순서대로 Closure를 추가합니다. 실행 부분인 applyAfterQueryCallbacks()는 다음과 같이 구현되어 있습니다.
여기서 사용되는 것은 ??가 아니라 PHP의 truthy 판정을 사용하는 ?:입니다. Collection을 직접 변경하고 아무것도 반환하지 않는 void 콜백도 사용할 수 있습니다. 이 경우 변경한 같은 객체를 유지합니다. 반면 새로운 Collection을 만든 경우에는 그 Collection을 반환해야 합니다. 내부의 인수와 반환값은 mixed이지만, 자유로운 타입 변환을 공개 API로 만드는 것은 피합니다. 예를 들어 get()의 결과를 문자열이나 배열로 바꾸면, first()가 이어서 호출하는 ->first() 등의 처리가 깨집니다. 스코프에서는 모델·Collection의 타입·건수·정렬 순서를 유지하는 가공을 기본으로 하세요.

조회 메서드별 차이

다음 표는 Eloquent 빌더에 등록한 afterQuery()의 처리입니다. first()는 내부에서 limit(1)->get()->first()를 호출합니다. first()를 사용한다고 해서 Closure의 인수를 Post로 변경해서는 안 됩니다.
이 페이지의 keyedById()는 모델을 조회하는 용도의 스코프입니다. Post::keyedById()->pluck('id')에는 사용할 수 없습니다. pluck()는 Illuminate\Support\Collection을 전달하므로 예제의 Illuminate\Database\Eloquent\Collection 타입과 일치하지 않습니다. 타입 선언을 완화하는 것만으로는 값에서 모델의 속성을 참조하는 처리가 안전해지지 않습니다.
값만 필요하다면 스코프를 사용하지 말고 Post::query()->pluck('id')로 합니다. 모델을 가공한 후에 값을 꺼내려면 Post::keyedById()->get()->pluck('id')로 쓸 수 있습니다. 후자의 pluck()는 쿼리 빌더가 아니라 조회한 Collection에 대한 조작입니다.

페이지 건수나 다음 조회 위치를 바꾸지 않기

paginate()의 총 건수는 SQL로 집계하고, 그 후에 페이지의 모델을 조회합니다. 조회 후에 Collection을 필터링해도 총 건수는 다시 계산되지 않습니다. 또한 simplePaginate()와 cursorPaginate()는 가공 후의 결과를 사용해 다음 페이지를 판단합니다. chunkById()는 청크 끝의 ID를 다음 조회 위치로 사용합니다. 콜백 안에서 건수나 순서를 바꾸면 페이지 판정이나 계속 위치가 원래 쿼리와 일치하지 않게 될 수 있습니다. 필터링·정렬·조회 컬럼은 SQL 측에서 지정하고, afterQuery()에서는 조회 결과의 의미를 바꾸지 않는 가공에 그칩니다. API의 표시 형식만 바꾸는 것이라면 API 리소스를 사용한 변환도 검토하세요. 공식 문서에는 모델에 검색 결과의 발췌 속성을 추가하는 예제가 있습니다. 모델 속성에 대한 대입은 저장 대상의 변경이 될 수도 있습니다. DB에 존재하지 않는 표시용 속성을 붙인 모델을 그대로 save()하는 설계는 피하세요.

등록은 쿼리 빌더에 남는다

afterQuery()를 호출할 때마다 Closure가 추가되며, 실행해도 등록은 사라지지 않습니다. 같은 빌더로 다시 조회하면 다시 실행되고, 같은 스코프를 두 번 호출하면 콜백도 두 번 등록됩니다. 빌더를 clone한 경우에도 등록된 콜백을 이어받습니다. 조회를 여러 번 수행하는 처리나 장수명 워커에서는 용도마다 새로운 쿼리를 만들고, 콜백은 부작용 없는 결과 가공으로 합니다. 메일 발송이나 영속화를 두면, 청크나 커서에 의한 여러 번의 실행을 업무상 1회의 처리로 착각하기 쉬워집니다. 패키지에서 재사용할 스코프를 제공하는 경우에도, 지원하는 조회 메서드를 공개 API로 명시하고 타입과 호출 횟수의 계약을 테스트하세요.

조회 경로의 차이를 테스트하기

사용하는 쪽의 Laravel 13 애플리케이션에서는 키 가공뿐만 아니라 first()·cursor()·빈 페이지에서의 호출을 확인합니다. 다음 예제는 앞서 본 Post 모델과, id·title·body·created_at·updated_at을 가진 posts 테이블의 마이그레이션이 있다는 것을 전제로 합니다. RefreshDatabase로 테스트 간의 레코드를 분리합니다. tests/Feature/AfterQueryTest.php에 테스트를 작성합니다.
대상 테스트만 실행할 수 있습니다.

유지보수 시 체크리스트

  • 스코프가 지원하는 조회 메서드와 Closure의 인수·반환값 타입을 명시한다.
  • get()의 빈 결과와 총 건수 0인 paginate()를 구별하여 검증한다.
  • 청크나 커서에서는 호출 횟수와 조회 순서가 바뀌지 않는지 확인한다.
  • 콜백의 이중 등록이나 빌더 재사용으로 업무상의 부작용이 발생하지 않도록 설계한다.
  • Laravel 업데이트 시에는 get()·pluck()·cursor()와 BuildsQueries의 호출 경로를 다시 확인한다.

관련 페이지

Eloquent 스코프

SQL 제약을 로컬 스코프와 글로벌 스코프로 모으는 기본을 확인합니다.

Eloquent 컬렉션

모델을 다루는 Collection과 Support Collection의 차이를 확인합니다.

Eloquent Observer와 모델 이벤트

조회 결과의 가공과는 다른, 모델 단위의 이벤트를 확인합니다.

API 리소스

API로 반환하는 표시 데이터의 변환을 모델의 저장 처리에서 분리합니다.

참고한 1차 정보

마지막 수정일 2026년 11월 6일